Search by

sameoldnick / laravel-geolocator

SameOldNick

A Laravel package for integrating geolocation services with your application.

Package info

github.com/SameOldNick/laravel-geolocator

Homepage

pkg:composer/sameoldnick/laravel-geolocator

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-09-20 07:27 UTC

This package is auto-updated.

Last update: 2026-09-20 07:30:29 UTC


README

Tests codecov

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-bcmath or ext-gmp, required for decoding larger integers with the pure PHP decoder
  • ext-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 the IPLOCATIONDB_*_PATH variables 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-For is only consulted when the request comes from a trusted proxy. Configure this with Laravel's TrustProxies middleware, 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.