torann/geoip
GeoIP for Laravel detects visitors' geographical location and currency from their IP addresses. Supports multiple geo services and requires publishing the config to set up a service.
Install and publish. Two commands, no migrations, no queue workers:
composer require torann/geoip
php artisan vendor:publish --provider="Torann\GeoIP\GeoIPServiceProvider" --tag=config
That drops config/geoip.php. Since v3.0.8 this step is mandatory, not optional — the bundled default driver was removed for MaxMind trademark reasons, so an unpinned install without a published config will error on first lookup.
Configure a driver. Open config/geoip.php, set 'default' => env('GEOIP_DRIVER', 'maxmind'), and supply credentials through env(). Supported drivers are maxmind (API or local DB), ipdata, ipfinder, ipapi, and ipgeolocation. Put the key in .env, not in the config file. For MaxMind's local database mode you also run the package's update command to fetch/refresh the .mmdb file and point config('geoip.services.maxmind.database_path') at it.
First use case — the one to ship first. Resolve the current visitor and use a cosmetic signal, never a gating one:
use Torann\GeoIP\Facades\GeoIP;
$location = GeoIP::getLocation(); // current request IP
$location = GeoIP::getLocation('8.8.8.8'); // explicit IP
$location->iso_code; // 'US'
$location->country; // ' United States'
$location->city; // 'Mountain View'
$location->currency; // 'USD'
Good day-one consumers: default currency display, default language hint, pre-filling a country select on a form, or attaching a country property to an analytics event. Bad day-one consumers: region-locked paywalls, tax calculation, legal jurisdiction decisions.
Where to look first. The README is thin; the real docs live at lyften.com/projects/laravel-geoip/doc/. Read the config file itself — it is more informative than the docs and shows every driver's expected keys and the cache block. Then read Torann\GeoIP\Location to see exactly which attributes you can rely on (ip, iso_code, country, city, state, postal_code, lat, lon, timezone, currency).
Register nothing. Laravel auto-discovery handles the service provider and the GeoIP facade alias.
Wrap the facade — do this on day one. The facade is convenient and it is also the thing you will want to delete when a provider's terms change. Define a one-method boundary in your app and let the package be its only implementation:
namespace App\Geo;
interface LocationResolver
{
public function forIp(?string $ip = null): ?GeoResult;
}
final class GeoIpResolver implements LocationResolver
{
public function forIp(?string $ip = null): ?GeoResult
{
try {
$loc = $ip ? GeoIP::getLocation($ip) : GeoIP::getLocation();
} catch (\Throwable $e) {
report($e);
return null;
}
return $loc->iso_code ? GeoResult::fromLocation($loc) : null;
}
}
Bind the interface in a service provider. Controllers, jobs, and models depend on LocationResolver. The facade name appears in exactly one file.
Null-safe by contract. getLocation() returns a Location object even on failure, but iso_code / city etc. come back empty or null. Never branch on $location->city === 'X' without a fallback. Pattern: resolver returns ?GeoResult, consumers supply a documented default (config('app.default_country'), the user's saved preference, 'USD', whatever the product already does when it doesn't know).
Cache at the package level, then again at yours. The config has a cache block (tags, service, ttl). Turn it on. Geo data moves on the order of weeks-to-months; a 24h–30d TTL is normal and it converts per-request provider calls into near-zero marginal cost and latency. The package caches by IP. If you also memoize within a request (once() or a request-scoped property), you avoid repeated cache-deserialization for the same visitor.
Use the middleware for request-level resolution. The package ships geoip middleware which resolves the location once per request and makes it available. Register it on the web group or on specific routes:
Route::middleware('geoip')->group(function () {
Route::get('/storefront', StorefrontController::class);
});
Keep it off API routes that don't touch location — it is not free, and on an unconfigured/offline provider it costs you a timeout on every request.
Explicit IPs in jobs, commands, and webhooks. There is no request IP in a queued job or an Artisan command. Always pass the IP explicitly there, and store it on the model (e.g. orders.ip_address) so the job can re-resolve later. Same for Stripe/Shopify webhooks — the caller's IP is the payment network's, not the customer's.
Trusted proxies first. Behind a load balancer or CDN, getLocation() will resolve the edge IP unless you have configured TrustProxies correctly. In Laravel 11+ set $middleware->trustProxies(at: ['10.0.0.0/8', /* your ALB ranges */]) and confirm the visitor IP is what you expect in request()->ip() before you trust any geo output.
One write path for "current currency". The pattern that holds up: a small service that reads GeoResult->currency, checks a session/users.preferred_currency override, falls back to app default, and returns a string. Every consumer — the currency formatter, the pricing presenter, the cart — calls that, not GeoIP::getLocation(). That gives you a single toggle point when you later switch to a local MaxMind DB or a hand-rolled resolver.
Local database mode when volume or residency pushes you there. For MaxMind, the driver can read from a local .mmdb instead of the web API. That is the right move if (a) per-call cost is non-trivial, (b) you can't send visitor IPs to a third party, or (c) latency on the request path is unacceptable. It trades a per-call cost for a scheduled refresh job — add the update command to your scheduler and alert on its failure, because a stale or missing .mmdb means every lookup silently degrades.
Config ownership is a real obligation. v3.0.8 is the cautionary tale: a patch-level release removed the default provider and broke installs that had never published config. Pin to a specific minor ("torann/geoip": "3.0.9"), read release notes before bumping, and add a CI smoke test that resolves a known public IP (8.8.8.8) through your configured driver and asserts iso_code === 'US'. That single test catches the entire class of "provider removed / config schema changed / credential missing" failures.
Never trust getLocation() on private or loopback IPs. 127.0.0.1, 10.x, 192.168.x, ::1, and malformed input appear constantly in staging, health checks, and internal traffic. The package will not necessarily throw, but it will not give you a meaningful answer either. Guard in your resolver:
if (!$ip || filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
return null;
}
X-Forwarded-For is spoofable. request()->ip() reads the forwarded header when proxies are trusted. If your trusted-proxy ranges are too wide (e.g. '*') any client can set the header. If they're too narrow (or unset), everyone looks like your edge. This is the single most common cause of "geo is broken in staging/prod but works locally." Verify with a log line of request()->ip() from a real client before debugging anything else.
Cache key ignores currency and driver. The package caches by IP. If you swap drivers or change credentials, stale entries from the old provider keep serving until TTL expires. During a provider migration, clear the configured cache store (or use a distinct cache tags value) rather than waiting it out.
Provider errors look like everything else. Timeout, quota-exceeded, bad credential, and "IP not in database" all surface as a Location with no iso_code (or an exception you catch). Instrument them distinctly — log the exception class, count failures by type. A rotated API key is invisible otherwise, and the symptom is "geo slowly degraded over a weekend," which nobody alerts on.
Accuracy ceiling, and what it means for your code. Country-level is dependable. City, postal code, and coordinates degrade sharply on mobile carriers, CGNAT, corporate VPNs, and privacy relays (Apple Private Relay, iCloud+, browser proxies). Any feature that would produce a wrong and harmful answer — regional pricing, access gating, tax jurisdiction, legal disclosures — is out of scope for this package. Documented, not discovered. This isn't a package defect; it's the nature of IP geolocation and the same is true of every provider.
The country attribute has a leading space. In several driver implementations /country comes back as ' United States' (from the upstream response parse). trim() in your GeoResult factory. Cheap to fix, annoying to find.
Currency is not locale. currency is a coarse country→currency mapping. It is correct for most of the world and wrong for eurozone edge cases, Zimbabwe, Panama, and anything with a strong USD/EUR local preference. Real currency selection needs the user's own choice first, geo second.
The maxmind local database path needs the parent directory to exist. The mkdir() bug (fixed in 1.2.1) still bites people who move the path to a custom location in the container. Create the directory in your Dockerfile and ensure the app user can write to it.
Extension points are thin — plan accordingly. The driver layer is not designed for user-supplied drivers in the way Laravel's cache or mail is. If you need an unsupported provider, the realistic path is: implement your own LocationResolver and skip the package for that provider, rather than writing a driver against the internal contract. The interface wrapper you already built makes this a one-file change.
Don't let it reach into domain logic. The moment GeoIP:: appears in a Model, an Observer, a Blade template, or a job's business logic, you have coupled your domain to a single-maintainer dependency with no SLA. Keep it in one resolver. That's the entire cost of insurance against the package being abandoned, and it's worth paying.
Ramp-up knowledge should not be one person. The docs live on lyften.com, not in the repo. Write a short internal note recording: which driver, where credentials live, cache TTL, fallback behavior, how to run the MaxMind refresh, and who owns the config file and upgrade cadence. That paragraph is the difference between a working integration and an incident when the one person who set it up leaves.
How can I help you explore Laravel packages today?