maxmind-db/reader
The official PHP API for reading MaxMind DB files, which store data indexed by IPv4 or IPv6 subnets. Install via Composer for the pure PHP implementation or the C extension for faster lookups.
maxmind-db/reader is the low-level engine behind every MaxMind .mmdb lookup in PHP. It is not a Laravel package — there is no service provider, no facade, no config file. You get one class (MaxMind\Db\Reader) and you wire it yourself. That is the first thing to internalize: budget ~30 minutes for glue code, not "install and go".
Install
composer require maxmind-db/reader:^1.14
Optionally add the C extension for speed (drop-in replacement, same class name, no code changes):
pie install maxmind-db/reader-ext # requires libmaxminddb
Also note the layering: geoip2/geoip2 (the package most Laravel tutorials reference) depends on this one and adds named accessors like $record->country->isoCode. If you only need the raw array, install this package directly and skip the wrapper.
Where to look first
MaxMind\Db\Reader::get() — returns an array (or null) for an IP.getWithPrefixLen() — returns [record, prefixLen], which is what you want for caching.metadata() — database type, build date, IP version. Use it to sanity-check the file you actually deployed.Get a database file. The reader does nothing without a .mmdb. GeoLite2 requires a (free) MaxMind account and license key; drop the file in storage/app/geoip/GeoLite2-City.mmdb and keep it out of git:
/storage/app/geoip/*.mmdb
Minimal wiring
// config/geoip.php
return [
'database' => env('GEOIP_DATABASE', storage_path('app/geoip/GeoLite2-City.mmdb')),
'cache_ttl' => env('GEOIP_CACHE_TTL', 86400),
];
// app/Providers/GeoIpServiceProvider.php
public function register(): void
{
$this->app->scoped(Reader::class, fn () => new Reader(config('geoip.database')));
}
scoped() rather than singleton() matters — see Gotchas.
First use case: resolve a country in a middleware and stash it on the request.
$record = app(Reader::class)->get($request->ip());
$request->attributes->set('geo', [
'country' => $record['country']['iso_code'] ?? null,
'city' => $record['city']['names']['en'] ?? null,
]);
That is genuinely the 80% case. Everything below is about doing it without footguns.
Wrap it behind an interface. Binding the vendor class directly into controllers is fine for a prototype and painful forever after. One thin abstraction pays for itself the first time you need to fake lookups in tests.
interface GeoLocator
{
public function locate(?string $ip): ?array;
}
final class MaxMindGeoLocator implements GeoLocator
{
public function __construct(private Reader $reader) {}
public function locate(?string $ip): ?array
{
if ($ip === null || ! filter_var($ip, FILTER_VALIDATE_IP)) {
return null;
}
return $this->reader->get($ip);
}
}
Cache by subnet, not by IP. get() is fast (microseconds), but if you call it in a loop over 100k log rows, you will feel it. getWithPrefixLen() gives you the network prefix, so 65,536 addresses in the same /16 share one cache entry.
public function locate(string $ip): ?array
{
$cached = Cache::get("geoip:{$ip}");
if ($cached !== null) {
return $cached['found'] ? $cached['record'] : null;
}
[$record, $prefixLen] = $this->reader->getWithPrefixLen($ip);
Cache::put("geoip:{$ip}", ['found' => $record !== null, 'record' => $record], 86400);
return $record;
}
Wrap the value in an array — Cache::remember() treats null as a miss, so caching "not found" naively re-hits the database on every private IP. See Gotchas.
Batch jobs. For importers and analytics backfills, resolve the reader once per job (constructor injection) rather than app(Reader::class) in the loop, and consider chunkById + a pre-warmed cache. Do not serialize the Reader into a queued job payload — it holds a file handle.
An Artisan command owns the file lifecycle. You need a scheduled job that downloads the updated database and a health check that catches a corrupt or stale file:
// app/Console/Commands/RefreshGeoIp.php
$path = config('geoip.database');
$tmp = $path.'.tmp';
// download via `geoipupdate` or the MaxMind download API into $tmp
rename($tmp, $path); // atomic on the same filesystem
$meta = (new Reader($path))->metadata();
$this->info("{$meta->databaseType} built {$meta->buildDate}");
The metadata check is worth keeping: databaseType catches "someone deployed the Country DB where City was expected", and buildDate catches "the cron silently stopped six months ago". Both are silent failures otherwise.
Validation rule. A custom rule that rejects IPs with no geo data is occasionally useful for anti-fraud forms:
Validator::extend('geo_resolvable', fn ($attr, $value) => app(GeoLocator::class)->locate($value) !== null);
Use sparingly — it fails on every private IP and every VPN exit node, which is usually not what the product wants.
API responses. Never dump the raw record. A City record is ~30 nested keys and half of them (traits, registered_country, represented_country) are noise or a privacy problem. Project it down to the three fields the frontend consumes.
Testing. Two viable approaches:
GeoLocator in the test container — fast, no fixture needed, covers your business logic..mmdb fixture (the reader repo ships small -Test.mmdb files you can vendor into tests/fixtures/) when you are asserting on actual MaxMind data shapes.Prefer the fake for feature tests; keep one integration test against a real fixture to catch schema assumptions.
get() returns null, and it is the common case. Private ranges (RFC1918), loopback, IPv6 link-local, and anything not in the database all return null. Laravel's $request->ip() behind a misconfigured proxy is 127.0.0.1 more often than anyone expects. Every access needs ?? null guards, and trustProxies needs to be correct or your geo data is uniformly "localhost".
Cache::remember() will not cache misses. null is treated as "not cached", so a burst of private IPs hammers the reader on every request. Use Cache::put() with an explicit ['found' => false] sentinel, as above.
Use scoped(), not singleton(), under Octane. A singleton holds the open file handle for the worker's lifetime. Swap the .mmdb on disk and long-lived workers keep serving from the unlinked inode until restart. scoped() resets the binding between requests/jobs, which is the correct lifetime for a file handle. Same reasoning applies to queue workers — they're long-lived too.
Replacing the file while it's open is platform-dependent. On POSIX, rename() is atomic and existing handles keep reading the old inode (stale but never corrupt). On Windows, you often cannot replace a file with an open handle at all — writes fail and the update job silently no-ops. Always download to a temp path and rename(); never truncate in place.
The C extension is a versioned dependency you don't control. Since 1.6.0, composer.json conflicts with extension versions older than the Composer package, so composer update can fail with a confusing message that is really "your PECL module is stale". Check with php -m | grep maxminddb before debugging your code. The extension also changes one observable behaviour: 128-bit integers come back as 0x… hex strings from the extension and decimal strings from pure PHP. No MaxMind DB uses that type today, but don't write code that assumes a format.
1.14.0 decoder limits are a real behavioural change. Each lookup is now capped at 65,536 values, 512 nesting levels, and 2 MiB of string payload; exceeding any of them throws InvalidDatabaseException. This protects you from a malicious database, but it also means a genuinely huge record that used to decode now throws. Catch InvalidDatabaseException at the boundary and treat it as "no geo data", not as a 500.
The reader throws on construction. A missing, unreadable, or directory path all blow up in the constructor — which, inside a service provider or middleware, means every request 500s. Wrap the resolution in a try/catch and degrade to "geo unavailable" rather than taking the app down over a missing file.
env() calls in the provider break under config:cache. Read the path from config('geoip.database') only. This bites people who inline env() in a service provider and then wonder why production resolves a null path.
Don't hold the reader in a property across a request boundary. It's a file handle, not a value object. Inject it, use it, let the container scope clean it up. close() exists but is largely unnecessary in FPM; in Octane it's redundant with scoped().
Database size vs. memory. GeoLite2-City is tens of MB; the C extension mmaps it, the pure-PHP reader seeks and reads per lookup. Neither loads the whole file into PHP memory, so a large file is a disk/IO concern, not a memory_limit concern — but do keep it on local disk, not a network mount.
Performance tier check. For typical Laravel web traffic, pure PHP is entirely adequate — you're talking microseconds against a request that costs milliseconds. Reach for pie install maxmind-db/reader-ext when you're doing bulk lookups (imports, log enrichment, analytics) or running thousands of lookups per second. Don't add a compile step to your deploy for a 3ms-per-request app.
Licensing is two-layered. The reader itself is Apache-2.0 and unproblematic. The database is not: GeoLite2 is CC BY-SA 4.0 and requires attribution ("This product includes GeoLite2 data created by MaxMind"), and the commercial GeoIP2 databases carry subscription terms. Put the attribution in your legal/credits page, not just the README.
Privacy. An IP is personal data under GDPR. Country-level resolution is typically defensible; storing precise city coordinates alongside a user ID is a different conversation. Resolve at the edge, store the derived country code, and discard the raw IP unless you have a stated retention reason.
How can I help you explore Laravel packages today?