hibit-dev/geodetect
Laravel package for detecting a visitor’s location from their IP address. GeoDetect offers configurable geolocation drivers, caching, and a simple API for resolving country, region, and city data in your application.
Install and publish config
composer require hibit-dev/geodetect
php artisan vendor:publish --tag=geodetect-config
The package is auto-discovered; the only file you normally need to touch is config/geodetect.php. Check the published file first — it tells you which driver is active, which API key/endpoint it needs, and whether caching is on by default.
Set the driver and credentials in .env
Typical keys are a driver selector plus a token for whichever upstream service you use:
GEODETECT_DRIVER=ipapi
GEODETECT_API_KEY=
GEODETECT_CACHE_TTL=86400
Never read these with env() outside the config file — php artisan config:cache will break it.
First use case: resolve the request's country once per session
The most common day-one integration is a middleware that detects the visitor's country and stores it on the request/session so controllers, Blade, and validation rules can all read it without re-querying:
// app/Http/Kernel.php (or bootstrap/app.php in Laravel 11+)
protected $middlewareGroups = [
'web' => [
// ...
\Hibit\GeoDetect\Http\Middleware\DetectGeo::class,
],
];
Then in a controller, read the detected payload (country code, city, timezone, currency — whatever the driver returns) off the request attribute or the package's facade/helper, and branch on it.
Where to look when something is off
config/geodetect.php — driver, cache, fallback values.Resolve once, read everywhere. Detection is an outbound HTTP call in most drivers. Don't call it in a Blade template or a model accessor. Do it in middleware or a small action class, then pass the DTO/array down. A simple app(GeoDetector::class)->forIp($ip) service call wrapped in Cache::remember("geo:{$ip}", now()->addDay(), ...) is the pattern that scales.
Session pinning for consistency. A user's IP can change mid-session (mobile handoff, VPN reconnect). If you use geo to pick a currency or locale, write the resolved value to the session on first detection and only re-detect on explicit user action. Otherwise prices/locales will flip under the user's feet.
Localization and currency switching. Country → locale and country → currency maps belong in your own config, not the package's. Store a small config/locales.php array keyed by ISO country code, and let the geo result be the default the user can override via a switcher. Persist the override on the user record so it survives IP changes.
Registration enrichment. On signup, capture the detected country/timezone onto the user row (a country_code column plus timezone). That gives you a permanent, auditable value instead of a per-request guess, and makes reporting/segmentation trivial later.
Content gating and compliance. For region-restricted content, treat geo as a hint, not an authorization boundary. Use it to hide shipping options or default a storefront, but confirm with billing address / account country before enforcing anything legal or financial.
Explicit IP for non-HTTP contexts. Queue workers, scheduled commands, and webhooks have no request IP. Pass the IP explicitly:
$geo = $detector->forIp($order->created_ip);
If the package only reads from the current request, keep the raw IP on the model at write time instead.
Test seam. Bind a fake/stub implementation of the detector contract in your test suite so feature tests don't hit the network:
$this->app->instance(GeoDetector::class, new FakeGeoDetector('DE'));
Assert on the downstream behavior (locale, currency) rather than on the detector itself.
Blade and API usage. Expose a single view composer or View::share variable ($geo) rather than calling the facade from templates. For JSON APIs, add the geo payload to a resource only when the client asks for it (?include=geo) — it's rarely needed on every response.
Localhost and private IPs return nothing. 127.0.0.1, 10.x, 172.16–31.x, 192.168.x, and ::1 are not routable. Always code a fallback (config('geodetect.fallback') or your own default) and make sure your local .env sets a test IP or a stubbed driver, otherwise every local page renders the fallback branch and you'll chase phantom bugs.
Proxy/CDN headers. Behind Cloudflare, a load balancer, or Heroku, $request->ip() returns the proxy. Configure TrustProxies correctly (or Laravel's TrustHosts/middleware ordering) before the geo middleware runs, or every visitor resolves to your datacenter's country.
Middleware order matters. The geo middleware must run after session start if it writes to the session, and after proxy trust. Registering it in the global stack instead of web is a common cause of "works on some routes, not others."
Rate limits and cost. Free tiers of IP-geolocation APIs are aggressively limited (per-minute and per-day). Enable the package's cache, key it by IP, and set a TTL that matches your tolerance for staleness (24h is fine for country; city accuracy is lower anyway). A single uncached call per request on a busy page will burn through quota fast.
Accuracy expectations. Country-level accuracy is high; city/ZIP is unreliable, especially on mobile carriers and VPNs. Never show a user "we think you're in " as a hard fact. Timezone derived from geo is often wrong near borders — prefer the browser's Intl.DateTimeFormat().resolvedOptions().timeZone and use geo only as a server-side default.
GDPR / privacy. An IP address is personal data under GDPR. If you're storing detected geo on the user record or logging it, mention it in your privacy policy, and avoid detecting before consent if you're using it for anything beyond strictly necessary localization. Caching IPs as keys in Redis is also processing.
IPv6. Confirm your driver handles IPv6 (2001:...). Some free endpoints silently fail or return null for v6, which means a chunk of mobile users get the fallback path.
Octane / long-running workers. Don't bind the resolved geo payload as a singleton in the container — under Octane it will leak across requests. Keep detection request-scoped (middleware, request attributes) and let the container resolve fresh instances.
Config caching. php artisan config:cache freezes config/geodetect.php. If you change drivers or add an API key and see no effect, run php artisan config:clear. Same trap as every Laravel package, but easy to forget during driver experiments.
Payload shape differs per driver. Don't assume city, region, or currency exist. Normalize the response into your own small DTO at the boundary so a driver swap doesn't ripple through controllers.
Extension points worth knowing. Most packages of this shape ship a driver contract — implement it and register your binding in a service provider to add an internal/self-hosted MaxMind database, which removes the network call and the rate limit entirely. If a facade is registered and macroable, GeoDetector::macro(...) is a clean place for app-specific helpers like isInEU().
Debugging tip. Log the resolved IP and the raw driver response at debug level behind a config flag for the first week in staging. Nine times out of ten a "wrong country" report is a proxy header or a stale cache entry, and that log line saves an hour.
How can I help you explore Laravel packages today?