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

Reader Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  1. MaxMind\Db\Reader::get() — returns an array (or null) for an IP.
  2. getWithPrefixLen() — returns [record, prefixLen], which is what you want for caching.
  3. metadata() — database type, build date, IP version. Use it to sanity-check the file you actually deployed.
  4. The release notes for 1.14.0 — decoder resource limits. Anyone running the pure-PHP reader should read that entry.

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.

Implementation Patterns

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:

  • Bind a fake GeoLocator in the test container — fast, no fixture needed, covers your business logic.
  • Use a real .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.

Gotchas and Tips

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.

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.
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table
siberfx/flysystem-wasabi
hollyit/token-replace
ippanelcom/laravel-sdk
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel