NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

Geolocation::fake() swaps the manager for a recording fake in your application tests and returns it. It records only: no provider, no network, no cache store and no MaxMind download is ever touched, and lookups answer with the results you seed. GeolocationFake extends GeolocationManager and replaces both the facade root and the container binding, so the facade, an injected manager and the $request->location() macro all use it:

use RoundlyConsulting\Geolocation\DataTransferObjects\Location;
use RoundlyConsulting\Geolocation\Enum\GeolocationType;
use RoundlyConsulting\Geolocation\Facades\Geolocation;

it('localises checkout from the visitor IP', function () {
    $fake = Geolocation::fake([
        '203.0.113.7' => new Location(
            humanReadable: 'Springfield, US',
            street: '',
            city: 'Springfield',
            countryIsoCode: 'US',
            latitude: 39.7817,
            longitude: -89.6501,
            type: GeolocationType::Ip,
        ),
    ]);

    $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.7'])
        ->get('/checkout')
        ->assertOk();

    $fake->assertLocated('203.0.113.7');
});

Seeding and asserting lookups

$fake = Geolocation::fake();

$fake->seed('203.0.113.7', $location)          // an IP
    ->seed('Main Street 1, Springfield', $hq)  // an address
    ->seed('48.1486,17.1077', $point)          // coordinates as "lat,lng"
    ->seedDefault($fallback)                   // anything not seeded
    ->seedDistance($distance);                 // returned by every distance() call

// ...exercise your code, then:
$fake->assertLocated('203.0.113.7');
$fake->assertProviderUsed('ipinfo');      // pinned via provider() / using()
$fake->assertProviderNotUsed('google');

// Or, for a code path that must not look anything up:
$fake->assertNothingLocated();

Distances, refreshes and the cache

use RoundlyConsulting\Geolocation\DataTransferObjects\GeolocationQuery;
use RoundlyConsulting\Geolocation\Enum\DistanceType;

// Distances — through distance() or distanceBetween(); every argument is optional
$fake->assertDistanceRequested(from: $warehouse);
$fake->assertDistanceRequested($warehouse, $customer, DistanceType::Driving);
$fake->assertNoDistanceRequested();

// MaxMind refreshes — Geolocation::updateDatabase() and geolocation:db:update
$fake->assertDatabaseUpdated('GeoLite2-City');   // or no edition for any refresh
$fake->assertDatabaseNotUpdated();

// Cache maintenance
$fake->assertForgotten(GeolocationQuery::forIp('8.8.8.8'));
$fake->assertNothingForgotten();
$fake->assertCacheFlushed();
$fake->assertCacheNotFlushed();

updateDatabase() on the fake records the edition and returns the configured path — and because geolocation:db:update calls it, running the command in a test is recorded too.

Every method

MethodPurpose
Geolocation::fake(array $results = [])Swap the manager for the fake; seed results keyed by IP, address or “lat,lng”.
seed(string $key, Location $location)Add one canned result (fluent).
seedDefault(?Location $location)Result for any lookup that has no seeded key.
seedDistance(?Distance $distance)Returned by every distance() call.
assertLocated(string $key)The key was looked up at least once.
assertNothingLocated()No lookup happened.
assertProviderUsed(string $name)A lookup, batch, distance or matrix ran pinned to this name via provider() or using() — any of the pinned names counts.
assertProviderNotUsed(string $name)No call was pinned to this name.
assertDistanceRequested(?$from, ?$to, ?$type)A distance was requested via distance() or distanceBetween(); each argument narrows the match.
assertNoDistanceRequested()No distance was requested.
assertDatabaseUpdated(?string $edition)The MaxMind database was refreshed (for that edition) — nothing is downloaded.
assertDatabaseNotUpdated()No database refresh happened.
assertForgotten($query)forget() ran for this GeolocationQuery or DistanceQuery.
assertNothingForgotten()forget() never ran.
assertCacheFlushed()flushCache() ran at least once.
assertCacheNotFlushed()flushCache() never ran.

The fake runs no provider, so assertProviderUsed() checks the names your code pinned with provider() / using() for a lookup, batch, distance or matrix — not which provider “would have answered”. The asserts are also callable statically on the facade — Geolocation::assertLocated('8.8.8.8'). Coordinate lookups are keyed “lat,lng” in plain decimals — “0.00001,0”, never “1.0E-5,0”; Coordinates(1.0, 2.0) becomes 1,2. distanceMatrix() on the fake returns an empty grid, and withToken() / withTimeout() / withConfig() are no-ops.

Faking the HTTP layer

To exercise a real provider’s response mapping, pin the pipeline and fake the HTTP call:

use Illuminate\Support\Facades\Http;

it('maps an IPinfo response onto a Location', function () {
    config()->set('geolocation.pipeline', ['ipinfo']);

    Http::fake([
        'ipinfo.io/*' => Http::response(['city' => 'Springfield', 'country' => 'US', 'loc' => '39.7817,-89.6501']),
    ]);

    expect(Geolocation::locateIp('203.0.113.7')?->city)->toBe('Springfield');
});

Faking the rate limiter

RateLimits::fake() from http-client-rate-limits-for-laravel records throttling without real sleeps, so you can assert a provider’s budget was applied:

use RoundlyConsulting\HttpClientRateLimits\Facades\RateLimits;

it('paces bulk lookups under the ipinfo budget', function () {
    config()->set('geolocation.pipeline', ['ipinfo']);
    config()->set('geolocation.services.ipinfo.rate_limits.limit', 1);

    $fake = RateLimits::fake();   // no real sleeping
    Http::fake(['ipinfo.io/*' => Http::response(['city' => 'X', 'country' => 'US', 'loc' => '1,2'])]);

    Geolocation::batch(['203.0.113.7', '198.51.100.4']);

    $fake->assertDeferred('geolocation:ipinfo:app');
});

Working on the package itself? Its own suite runs with:

composer test

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.