huluti/geoip2-bundle
Symfony bundle for MaxMind GeoIP2 that resolves IP addresses to geographic data such as country, city, and coordinates using local databases, with service integration for looking up visitor locations in applications.
What changed in 3.0.0 — read this before you install. This is a major release and the first release under the fork (the package descends from gpslab/geoip2). The headline items for a consumer are: PHP 8.4/8.5 support, old PHP and Symfony versions dropped, and the legacy "geoip v2" dependency line dropped. That last one is the one to verify yourself rather than assume — open the released composer.json and check the actual constraint on the underlying GeoIP library (and on symfony/*) before you plan an upgrade. The UPGRADE-3.0.md file shipped with the release is the authoritative migration doc; read it first, then the bundle's DI extension class, then the README. (The PHPUnit/PHPStan bumps in the notes are dev-only — interesting as a signal that the fork is maintained with CI on modern runtimes, not as something you act on.)
Then the framework reality check, which has not changed. huluti/geoip2-bundle is a Symfony bundle, not a Laravel package. No service provider, no facade, no config/geoip.php. In Laravel you will almost certainly install its dependency directly (geoip2/geoip2) and wire it yourself. Treat this assessment as: what this bundle teaches you about structuring GeoIP2 in a PHP app, and how to reproduce that structure in Laravel.
If you are in a Symfony app, the minimal path:
composer require huluti/geoip2-bundle:^3.0
Register the bundle in config/bundles.php, then point it at your MaxMind database:
# config/packages/huluti_geoip2.yaml
huluti_geoip2:
database_path: '%kernel.project_dir%/var/GeoLite2-City.mmdb'
Because 3.0.0 is a fork re-release, do not copy the config root or service IDs from pre-3.x tutorials — confirm both against the installed bundle (the extension class + services.yaml). Wrong keys in Symfony usually fail silently (defaults applied), which is far worse than a hard error.
You still need the .mmdb file. GeoLite2 requires a free MaxMind account and license key; the file is not bundled.
First use case: resolve a request IP to a country/city for geo-targeted content, currency/locale defaults, or a coarse fraud signal. Inject the reader service and call ->city($ip):
public function __construct(private \GeoIp2\Database\Reader $reader) {}
public function locate(string $ip): array
{
$record = $this->reader->city($ip);
return [
'country' => $record->country->isoCode,
'city' => $record->city->name,
'lat' => $record->location->latitude,
'lng' => $record->location->longitude,
];
}
This snippet is stable across the GeoIP2 major versions, but if you're on a freshly-upgraded underlying library, sanity-check the record accessors once against the docs for the version you actually installed.
Port the bundle's shape to Laravel. Its whole job is: read config, construct a Reader, expose it as a singleton. Do the same:
// app/Providers/GeoIpServiceProvider.php
public function register(): void
{
$this->app->singleton(\GeoIp2\Database\Reader::class, fn () => new \GeoIp2\Database\Reader(
config('services.geoip.database', storage_path('app/geoip/GeoLite2-City.mmdb'))
));
}
Bind as a singleton — the Reader memory-maps the file and should be built once per process, not per call. Mirror the bundle's 3.0 constraints in your own composer.json (PHP and the underlying GeoIP library) so your Laravel app and any Symfony service you share code with don't drift onto different library majors.
Middleware pattern (the day-to-day one). Resolve and stash once per request instead of re-reading in every controller:
public function handle($request, Closure $next)
{
$ip = $request->ip();
$request->attributes->set('geo', $this->safeCity($ip)); // null on failure
return $next($request);
}
Guard every lookup. AddressNotFoundException is thrown for private/reserved IPs (127.0.0.1, 10.x, 192.168.x, ::1). In local dev every request throws, so wrap and return null:
try {
return $reader->city($ip);
} catch (\GeoIp2\Exception\AddressNotFoundException $e) {
return null; // localhost, LAN, reserved ranges
}
Cache per-IP results. MMDB lookups are microseconds, but in hot paths Cache::remember("geo:{$ip}", 3600, ...) is cheap insurance. Don't cache null/private-IP failures forever if you test from varying local IPs.
Database reader vs. web service. Depending on version the bundle exposes the local Reader and/or MaxMind's WebService\Client (user_id + license_key); the 3.0 notes' dependency drop is a good reason to re-check which providers you still get. Default to the database reader — no network hop, no per-query cost, no rate limits. Use the web service only for data GeoLite2 lacks (Enterprise, anonymizer/ISP datasets) and when you can accept the latency.
Keep lookups at the edge of your domain. Resolve to a small value object (Country, Coordinates) in a dedicated service and keep GeoIp2\* types out of controllers/models. That makes the reader mockable and lets you swap providers without touching business logic — extra valuable here, given the package just changed maintainership and jumped a major version.
Update pipeline. GeoLite2 refreshes weekly (city) / monthly-ish (country). Wire a scheduled command to re-download the .mmdb, swap it atomically, then recycle workers so the new file is memory-mapped. Stale files silently serve stale geolocation — nothing errors.
composer require will fail or force a wide upgrade — check constraints before bumping, and run UPGRADE-3.0.md top to bottom rather than improvising.services.yaml, not the internet.composer require huluti/geoip2-bundle will not give you a usable service provider; you'll get class-not-found or Symfony-only services in a container Laravel never builds. Use geoip2/geoip2 directly..mmdb is not included. Fresh installs fail at runtime with InvalidDatabaseException or file-not-found if the path is wrong. Add a bootstrap check that verifies the file exists and fails loudly at boot, not on the first lookup.download.maxmind.com with account_id/license_key. Don't commit the .mmdb — tens of MB, versioned by date.$request->ip() is the proxy; configure TrustProxies (Laravel) / trusted proxies (Symfony) or you'll geolocate your own datacenter. Normalize/validate the IP first — a malformed string raises InvalidArgumentException.new Reader(...) per call re-opens and re-maps the file — measurable under load. Bind once; in long-running workers (Octane, queue daemons) restart after a data update. Now that PHP 8.4/8.5 are explicitly supported, this matters more, not less: long-lived workers on new runtimes are exactly where a stale mmap bites.8.8.8.8 → US) in a single integration test, skipped when the .mmdb is absent.^3.0, read UPGRADE-3.0.md, and keep treating the patterns — singleton reader, config-driven path, safe wrapper — as the durable takeaway rather than betting your architecture on the bundle.How can I help you explore Laravel packages today?