Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Geoip2 Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

  • 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: write an integer, not a string, and check the result. The new 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.
  • The downloader needs a writable directory, not just a readable file. 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.
  • Licensing. GeoLite2 may not be redistributed. Do not commit .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.
  • Missing file = runtime failure at first lookup. Path typos don't fail at container compile time; they throw the first time a request geolocates. Add a health check that asserts each configured path exists and is a valid mmdb (open it once with Reader), and run it in your smoke test suite — you shouldn't discover a broken download at 3am.
  • Keep the reader as a shared service. MaxMind uses memory-mapped I/O; the map is shared across PHP-FPM workers via the page cache, so holding one reader is cheap. Instantiating 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.
  • Proxy/CDN headers decide your answer. Behind Cloudflare/ALB, 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.
  • VPN, mobile carriers, and satellite ISPs make this approximate. Country-level is reliable enough for locale and legal defaults; city-level is not reliable enough for tax jurisdiction, delivery pricing, or fraud scoring on its own. Cross-check with billing address and account history.
  • GDPR note. An IP address tied to an individual is personal data in the EU. If you store derived location, document the lawful basis, keep a retention limit, and consider truncating the last octet before logging.
  • IPv6 support is fine but only if your db edition includes it (all current MaxMind editions do) and your REMOTE_ADDR handling isn't IPv4-only.
  • Maintenance footprint improved with 2.2.0, but stays small. Symfony 8 support means the wrapper is tracking Symfony majors proactively — good signal — though it's still a low-star, single-maintainer package. Pin the version, read the changelog on minor bumps (2.2.0 was additive: Symfony 8, downloader permissions, downloader proxy — no BC breaks for readers), and remember the escape hatch: a 20-line factory service around geoip2/geoip2 directly replaces the bundle if needed.
  • Extension point worth using: define your own narrow 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings