gpslab/geoip2
Laravel wrapper for MaxMind's GeoIP2 databases. Look up a visitor's country, city, coordinates, and timezone from their IP address, with a service provider, facade, and config included. Works with MaxMind's downloadable database files and the web service API.
gpslab/geoip2 is a small Symfony DI wrapper around the official geoip2/geoip2 reader. It turns a .mmdb file (or an on-demand download of one) into an injectable service. v2.2.0 adds Symfony 8 support and two refinements to the built-in downloader — file permissions and HTTP proxy support — so the bundle now covers the full "fetch the database, then read it" loop. Plan on ~100 lines of glue in the bundle and the real work living in MaxMind's Record objects.
Minimal setup:
composer require gpslab/geoip2
// config/bundles.php
return [
// ...
GpsLab\Bundle\GeoIP2Bundle\GpsLabGeoIP2Bundle::class => ['all' => true],
];
MaxMind requires a (free) account and license key since Dec 2019. Put the key in the environment, not in YAML:
MAXMIND_LICENSE_KEY=xxxxxxxxxxxxxxxx
# config/packages/gpslab_geoip2.yaml
gpslab_geoip2:
databases:
city:
path: '%kernel.project_dir%/var/geoip/GeoLite2-City.mmdb'
type: city
edition: GeoLite2-City
license_key: '%env(MAXMIND_LICENSE_KEY)%'
permissions: 0644 # v2.2.0+, see gotchas on the octal/string footgun
country:
path: '%kernel.project_dir%/var/geoip/GeoLite2-Country.mmdb'
type: country
default: city
Then fetch the databases with the bundle's own download command (confirm the exact name/flags with bin/console list geoip — it is the command shipped in src/Command/):
bin/console geoip:download # or geoip2:download, depending on your version
ls -l var/geoip/ # verify the mmdb exists and is readable by PHP-FPM
And inject the bundle's interface (aliased to the default database):
use GpsLab\Bundle\GeoIP2Bundle\GeoIP2Interface;
final class LocaleResolver
{
public function __construct(private GeoIP2Interface $geoip2) {}
public function countryFor(string $ip): ?string
{
try {
return $this->geoip2->getRecord($ip)->country?->isoCode;
} catch (\GeoIp2\Exception\AddressNotFoundException) {
return null; // localhost, private ranges, unallocated IPs
}
}
}
Where to look first: vendor/gpslab/geoip2/src/Resources/config/ for service IDs and the DI tag used per database, src/DependencyInjection/Configuration.php for the authoritative option list (this is where permissions and the proxy setting landed in 2.2.0), then src/Command/ for the downloader's actual name and flags. The package is small enough that reading all of src/ takes ten minutes and removes all ambiguity.
Best first use case: resolve country → default locale/currency in a kernel.request listener, with a session/user override. It's read-only, easy to cache, and immediately visible in QA. Do not start with city-level geolocation for fraud or pricing logic; accuracy and VPN prevalence will burn you.
One database vs. many. Configure only what you use. City records contain country data anyway; a separate Country DB (~5 MB vs ~60 MB) is only worth it if you deliberately want a cheaper/faster reader or you're on memory-constrained workers. When you need a non-default database, inject it by its configured name — check the bundle's service definition for the exact ID/alias pattern (gpslab_geoip2.city style) and prefer binding it explicitly in services.yaml rather than relying on autowiring magic.
Let the bundle download, then schedule it. Since the bundle already fetches the mmdb, don't write your own tarball-extracting command — schedule the shipped one weekly (GeoLite2 updates twice a week, so weekly is a safe floor) and let it write straight into the configured path:
// app/Scheduler.php (Symfony Scheduler) — or a plain cron entry
$schedule->command('geoip:download')->weekly()->sundays()->at('03:00');
Two things to verify after each run: the file mtime changed, and the mode matches what PHP-FPM can read (that's exactly what the new permissions option is for). If you run the downloader as a deploy user and the web server under a different UID, set permissions: 0644 rather than relying on umask.
Behind an egress proxy or air-gapped CI. v2.2.0 adds proxy support to the downloader. If your build agents or production hosts reach the internet only through a corporate proxy, configure it at the download step (check Configuration.php for the exact key — a proxy string/array on the database definition is the shape to look for) or via the conventional HTTPS_PROXY env var, and keep the value env-driven so dev machines don't inherit it. Fully air-gapped environments still need the "copy an mmdb onto the host" path, so keep the path config pointing at a stable location you can populate out-of-band.
Request listener for locale/currency. Resolve once per request, store the result on the request attributes, and never look it up twice:
final class GeoIpListener
{
public function __construct(
private GeoIP2Interface $geoip2,
private LocaleSwitcher $switcher,
) {}
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
if ($request->hasPreviousSession() && $request->getSession()->has('_country')) {
return; // user preference wins over IP
}
$record = $this->safeLookup($request->getClientIp());
$country = $record?->country?->isoCode;
$request->attributes->set('_geo_country', $country);
$this->switcher->apply($country);
}
}
Register it at priority > 0 (before the Locale listener) and skip it for stateless/high-volume routes — a lookup on every API call is wasted work.
Cache aggressively; the reader is fast, the object graph isn't free. A PSR-6 decorator keeps hot paths flat and gives you one place to add a TTL tied to your mmdb refresh cadence:
final class CachedGeoIP2 implements GeoIP2Interface
{
public function __construct(
private GeoIP2Interface $inner,
private CacheItemPoolInterface $cache,
) {}
public function getRecord(string $ip): \GeoIp2\Model\AbstractModel
{
$key = 'geoip_'.hash('xxh128', $ip);
$item = $this->cache->getItem($key);
if ($item->isHit()) {
return $item->get();
}
$record = $this->inner->getRecord($ip);
$this->cache->save($item->set($record)->expiresAfter(86400)); // serialize the record
return $record;
}
}
Records are JsonSerializable, so serializing for cache is safe; if you'd rather not cache whole models, cache a small DTO (country, city, lat, lon, timezone) instead — it keeps your cache schema stable across geoip2/geoip2 upgrades and across the mmdb swap that the downloader performs.
Twig extension for the common read paths. A country_name / country_flag filter beats repeating try/catch in templates, and it centralizes the fallback for unknown IPs:
{{ geo_country_name(app.request.attributes.get('_geo_country')) }}
Integrate via decorators, not forks. Everything you'll want to change (caching, metrics, fallback to a second provider) is expressible as a decorator on GeoIP2Interface. Bind the decorator in services.yaml with decorates: and leave the bundle untouched — this survives composer update.
Testing. Never hit a real mmdb in unit tests: bind a stub GeoIP2Interface that returns hand-built \GeoIp2\Model\City objects from new City([...]), or reuse the maxmind-db/test-data fixtures. One functional test with the real file is enough to catch config/bundling mistakes. Keep the downloader out of CI entirely — a test suite that depends on a license key and MaxMind's CDN is a flaky suite.
AddressNotFoundException is the normal case, not an error. Localhost, 10.x, 192.168.x, 172.16–31.x, ::1, and unallocated IPs all throw it. Wrap every call site or you'll ship 500s in dev. Also guard $request->getClientIp() returning null behind a proxy.null fields are everywhere. country->isoCode can be null even for a valid record; city->name is unavailable in Country DBs. Use nullsafe access and always have a fallback. city->names is a map (en, de, ru…), not a string — pick a language or use ->name.permissions option is the fix for the classic "deploy user can read it, www-data can't" failure. Pass 0644 as a YAML scalar and be aware YAML 1.1 parses leading-zero integers as octal — which is what you want for chmod, but "0644" (quoted) becomes a string and will either fail validation or be silently ignored. After the first download, ls -l the file as the PHP-FPM user before trusting it.var/geoip/ must exist and be writable by whoever runs the command (var/ is usually gitignored, so a fresh checkout won't have it). Create it in the deploy script before the first download; a missing directory surfaces as a confusing download failure rather than a clear config error..mmdb files to git, do not bake them into public Docker images, and display MaxMind attribution where required. Keep MAXMIND_LICENSE_KEY in the secret store and out of build logs — the downloader will echo URLs that contain it if you run with -v.Reader), and run it in your smoke test suite — you shouldn't discover a broken download at 3am.Reader per request (or per lookup) is not — and if you ever swap the mmdb underneath, the old inode stays mapped until workers recycle, so plan a graceful reload after each download.getClientIp() needs trusted_proxies/trusted_headers configured correctly or every user geolocates to your load balancer's region. Same class of bug applies to the downloader's proxy setting: an outbound proxy that's set in your shell but not in the FPM/worker environment will work in CI and fail nightly.REMOTE_ADDR handling isn't IPv4-only.geoip2/geoip2 directly replaces the bundle if needed.GeoLocatorInterface in your app, implement it with the bundle's service, and inject that. It costs ten minutes now and insulates your domain code from this package's lifecycle, its testing story, and any future swap to a different provider.How can I help you explore Laravel packages today?