laravel-geolocator maintained by sameoldnick
Laravel Geolocator
An offline IP geolocation package for Laravel, backed by MaxMind databases. Look up the country, city and ASN behind an IP address, resolve the location of the current request, and keep the databases up to date with a scheduled Artisan command.
Requirements
- PHP 8.4 or newer
- Laravel 11, 12 or 13 (
illuminate/contracts ^11.0 || ^12.0 || ^13.0) - Composer
The MaxMind database reader is pure PHP, but MaxMind recommends one of the following extensions:
ext-bcmathorext-gmp, required for decoding larger integers with the pure PHP decoderext-maxminddb, a C-based decoder that provides significantly faster lookups
Installation
composer require sameoldnick/laravel-geolocator
The service provider is auto-discovered. Publish the configuration file:
php artisan vendor:publish --tag=geolocator-config
Download the MaxMind databases:
php artisan geolocator:update-iplocationdb
The package does not ship the databases. The command downloads the country, city and ASN editions
(both IPv4 and IPv6) into storage/app/geolocation. Lookups throw an InvalidArgumentException
when the configured database file is missing, so run the command once after installing, or point
the package at databases you already have.
Configuration
Everything lives under the geolocator config key; the values below can be set in config/geolocator.php
or through the environment.
| Environment variable | Default | Description |
|---|---|---|
GEOLOCATOR_DRIVER |
iplocationdb |
Driver used by the manager |
IPLOCATIONDB_COUNTRY_DB_PATH |
storage/app/geolocation/GeoLite2-Country.mmdb |
Country edition, IPv4 |
IPLOCATIONDB_COUNTRY_DB_PATH_V6 |
storage/app/geolocation/GeoLite2-Country-IPv6.mmdb |
Country edition, IPv6 |
IPLOCATIONDB_CITY_DB_PATH |
storage/app/geolocation/GeoLite2-City.mmdb |
City edition, IPv4 |
IPLOCATIONDB_CITY_DB_PATH_V6 |
storage/app/geolocation/GeoLite2-City-IPv6.mmdb |
City edition, IPv6 |
IPLOCATIONDB_ASN_DB_PATH |
storage/app/geolocation/GeoLite2-ASN.mmdb |
ASN edition, IPv4 |
IPLOCATIONDB_ASN_DB_PATH_V6 |
storage/app/geolocation/GeoLite2-ASN-IPv6.mmdb |
ASN edition, IPv6 |
MAXMIND_AUTO_UPDATE |
true |
Register the update command on the schedule |
MAXMIND_UPDATE_FREQUENCY |
weekly |
hourly, daily, weekly, monthly, or a cron expression |
The databases are GeoLite2 data redistributed by the ip-location-db project; review that project's licensing and attribution terms before redistributing the files yourself.
Deployment notes
- Network: the update command downloads over HTTPS from a public CDN, so whatever machine runs it needs outbound access (or a proxy) at install and update time. Lookups themselves are offline.
- Filesystem: the databases are written to
storage/app/geolocation, which needs to be writable and large enough for the editions you enable (the city edition is the largest). On a read-only or ephemeral filesystem, either bake the databases into your image or point theIPLOCATIONDB_*_PATHvariables at a writable mount. - Long-running workers: the ip-location-db driver caches one reader per database path for the lifetime of the process, so restart queue workers and Octane after updating, otherwise they keep reading the handle they opened at boot.
Usage
Resolving the geolocator
use SameOldNick\Geolocator\Contracts\Geolocator as GeolocatorContract;
use SameOldNick\Geolocator\Facades\Geolocator;
Geolocator::lookup('8.8.8.8'); // facade
app(GeolocatorContract::class)->lookup('8.8.8.8'); // contract
app('geolocator')->lookup('8.8.8.8'); // container alias
All three resolve the same manager instance. The contract is imported under an as alias because the
facade and the contract share their short name. The package also registers a global Geolocator
facade alias through extra.laravel.aliases, so the shortest entry point needs no import:
\Geolocator::lookup('8.8.8.8');
Looking up an address
$location = Geolocator::lookup('8.8.8.8'); // country, city and ASN
$country = Geolocator::lookupCountry('8.8.8.8'); // country only
$city = Geolocator::lookupCity('8.8.8.8'); // city only
$asn = Geolocator::lookupAsn('8.8.8.8'); // ASN only
Each returns a LocationResult, with the editions you did not ask for left as null:
$location->ipAddress; // '8.8.8.8'
$location->country?->countryCode; // e.g. 'US'
$location->country?->countryName; // e.g. 'United States'
$location->country?->getCoordinates(); // e.g. ['latitude' => 37.09, 'longitude' => -95.71]
$location->city?->city; // e.g. 'Ashburn'
$location->city?->state1; // e.g. 'Virginia'
$location->city?->timezone; // e.g. 'America/New_York'
$location->asn?->asn; // e.g. 15169
$location->asn?->organization; // e.g. 'Google LLC'
$location->hasResults(); // true when any edition returned data
$location->toArray();
Private, reserved and unparseable addresses return no records, so the result is empty:
Geolocator::lookup('192.168.1.1')->hasResults(); // false
(string) Geolocator::lookup('192.168.1.1'); // 'Unknown Location'
Resolving the current request
The package registers a geolocate macro on the request:
Route::get('/location', function (Illuminate\Http\Request $request) {
return $request->geolocate()->toArray();
});
It takes the first non-private address from $request->getClientIps(), falls back to
$request->ip(), and finally to '0.0.0.0' when the request carries no address at all.
Pass your own default with $request->geolocate('127.0.0.1').
X-Forwarded-Foris only consulted when the request comes from a trusted proxy. Configure this with Laravel'sTrustProxiesmiddleware, otherwise the header is ignored and the proxy's own address is used.
Faking lookups in your tests
use SameOldNick\Geolocator\Drivers\FakeGeolocator;
use SameOldNick\Geolocator\DTOs\AsnResult;
use SameOldNick\Geolocator\DTOs\LocationResult;
use SameOldNick\Geolocator\Facades\Geolocator;
/** @var FakeGeolocator $driver */
$driver = Geolocator::fake();
Geolocator::lookup('8.8.8.8'); // random result, no database access
// Return a specific result for an address.
$driver->mock('8.8.8.8', new LocationResult(
ipAddress: '8.8.8.8',
country: null,
city: null,
asn: AsnResult::create(15169, 'Google LLC'),
));
// Or mock an address that resolves to nothing.
$driver->mock('1.1.1.1');
By default fake() returns an empty result for 10% of public addresses, which is what makes the fake
non-deterministic. Pass a different percentage, or 0 when you need real data for every public
address:
Geolocator::fake(0);
The fake stays installed for the rest of the test, which is usually what you want since every test gets a fresh container. To hand the facade back to the configured driver mid-test, swap its real root back in:
Geolocator::swap(app(\SameOldNick\Geolocator\GeolocatorManager::class));
Writing a custom driver
Implement SameOldNick\Geolocator\Contracts\Geolocator and register it with extend():
use SameOldNick\Geolocator\Contracts\Geolocator as GeolocatorContract;
use SameOldNick\Geolocator\DTOs\LocationResult;
use SameOldNick\Geolocator\Facades\Geolocator;
class MyGeolocator implements GeolocatorContract
{
public function lookup(string $ip): LocationResult { /* ... */ }
public function lookupCountry(string $ip): LocationResult { /* ... */ }
public function lookupCity(string $ip): LocationResult { /* ... */ }
public function lookupAsn(string $ip): LocationResult { /* ... */ }
}
Geolocator::extend('my-driver', fn ($app) => new MyGeolocator());
Select it with GEOLOCATOR_DRIVER=my-driver, or by setting geolocator.driver in the config file.
A driver name is resolved either by a registered creator (as above) or by a createXDriver() method
on the manager. Any other name throws InvalidArgumentException: Driver [x] not supported. — the
container is not consulted, so a container binding alone will not register a driver.
Keeping the databases up to date
php artisan geolocator:update-iplocationdb # update now
php artisan geolocator:update-iplocationdb -v # log each step, with context
Downloads are retried across every URL configured for an edition. While MAXMIND_AUTO_UPDATE is
enabled the command is also registered on the scheduler (MAXMIND_UPDATE_FREQUENCY, weekly by
default) and runs withoutOverlapping().
Events
| Event | Dispatched when | Payload |
|---|---|---|
DatabaseFileUpdated |
a database file was replaced | edition, ipVersion, localPath |
DatabaseFileUpdateFailed |
a file was skipped or every URL failed | edition, ipVersion, localPath, reason |
DatabaseUpdatesCompleted |
a full update run finished | total, successful, failed, hasFailures() |
Each file update also reports progress through the optional callback passed to
Updater::update(callable $callback).
The package emits these events but sends no notifications itself — register a listener to notify whatever your application uses (mail, Slack, database notifications).
AI Guidelines
The package ships a Laravel Boost guideline at
resources/boost/guidelines/core.blade.php. When a Boost user runs php artisan boost:install, or
php artisan boost:update --discover after installing this package, the guideline is merged into
their agent's context. It covers the parts that are easy to get wrong: a missing database throwing
instead of returning an empty result, the aliasing needed when importing the facade and the contract
together, the geolocate request macro, and why X-Forwarded-For must not be parsed by hand.
Nothing depends on Boost in either direction: discovery is by convention from
vendor/sameoldnick/laravel-geolocator/resources/boost, so the guideline costs users who do not use
Boost nothing at all. tests/Feature/BoostGuidelineTest.php renders it, because Boost silently skips
a guideline that fails to render as Blade, and asserts it is not stripped by .gitattributes.
There is no agent skill to go with it. The package has no multi-step authoring workflow for an agent to improvise — setup is a one-time console task — so a guideline is enough.
Testing
composer test # Pest test suite
composer test-coverage # the same, with coverage
composer format # Pint code style
vendor/bin/pest tests/Feature/GeolocatorTest.php # a single file
The runner is Pest, but the tests are plain PHPUnit classes. tests/TestCase.php boots Orchestra
Testbench, and composer serve starts the workbench demo application.
Test doubles live in tests/Fixtures: RecordingGeolocator records the calls a driver receives,
and ScriptedUpdater scripts the outcome of each download. Real MaxMind databases are not committed,
so the ip-location-db driver is covered through its path selection and its failure mode.
The examples in this file are covered by tests/Feature/ReadmeExamplesTest.php, and the reference
tables and manifest claims above by tests/Unit/ReadmeReferenceTest.php, so the documentation cannot
drift from the code.
Static analysis runs with vendor/bin/phpstan analyse src --memory-limit=1G. The composer analyse
and composer lint scripts currently fail, because phpstan.neon lists a routes path this package
does not have and the default 128M memory limit is too low for level 7.
Changelog
See CHANGELOG.md for what has changed recently. This project follows Semantic Versioning.
Contributing
Pull requests are welcome. Please keep the suite green and the code style applied before opening one:
composer test # the test suite
composer format # the code style
Security
Please review the security policy before reporting a vulnerability, and do not open a public issue for security problems.
License
This package is open-sourced software licensed under the MIT license.