Setting Up Geo-IP Region Detection on Website
Region detection by IP is foundation for content personalization, regional pricing and analytics. Looks simple but has nuances: database accuracy, IPv6, proxies and VPN, caching, data updates.
Choosing GeoIP Database
MaxMind GeoLite2 — free base, requires registration and license key. Updates every Tuesday. City accuracy for Russia around 80%, countries 98%+.
MaxMind GeoIP2 City — paid version with higher accuracy and ISP data included. Justified for e-commerce with regional pricing.
ip-api.com / ipinfo.io — HTTP API, no local db. Suits small load (thousands requests/day). Adds ~50–200 ms delay on each first visit.
For production site with significant load — only local MaxMind database.
Installing MaxMind GeoLite2
# Install geoipupdate for auto-updates
apt-get install geoipupdate
# /etc/GeoIP.conf
AccountID 123456
LicenseKey your_key_from_account
EditionIDs GeoLite2-City GeoLite2-Country
# Initial download
geoipupdate
# Cron: Wednesday 3:00 (base updates Tuesdays)
0 3 * * 3 /usr/bin/geoipupdate
Database saved to /var/lib/GeoIP/GeoLite2-City.mmdb.
PHP: geoip2/geoip2 Package
composer require geoip2/geoip2
// app/Services/GeoIpService.php
use GeoIp2\Database\Reader;
use GeoIp2\Exception\AddressNotFoundException;
class GeoIpService
{
private Reader $reader;
public function __construct()
{
$this->reader = new Reader(config('geoip.database_path'));
}
public function lookup(string $ip): array
{
// Don't lookup RFC1918 addresses — intranet
if ($this->isPrivateIp($ip)) {
return $this->defaultResult();
}
try {
$record = $this->reader->city($ip);
return [
'country_code' => $record->country->isoCode, // 'RU'
'country_name' => $record->country->name, // 'Russia'
'region_code' => $record->subdivisions[0]?->isoCode, // 'MOW'
'region_name' => $record->subdivisions[0]?->name, // 'Moscow'
'city' => $record->city->name, // 'Moscow'
'latitude' => $record->location->latitude,
'longitude' => $record->location->longitude,
'timezone' => $record->location->timeZone, // 'Europe/Moscow'
'is_vpn' => false, // GeoLite2 doesn't detect VPN
];
} catch (AddressNotFoundException) {
return $this->defaultResult();
}
}
private function isPrivateIp(string $ip): bool
{
return filter_var($ip, FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false;
}
private function defaultResult(): array
{
return [
'country_code' => config('geoip.default_country'),
'region_name' => null,
'city' => null,
'timezone' => config('app.timezone'),
];
}
}
Getting Real IP Behind Proxy/Load Balancer
Nginx and cloud load balancers send original IP through headers. Only trust known balancer IPs:
// config/trustedproxies.php or bootstrap/app.php (Laravel 11)
->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(
proxies: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
headers: Request::HEADER_X_FORWARDED_FOR
);
})
Cloudflare sends real IP in CF-Connecting-IP header — handle separately:
public function getClientIp(Request $request): string
{
// Cloudflare
if ($cf = $request->header('CF-Connecting-IP')) {
return $cf;
}
return $request->ip();
}
Caching Results
MMDB lookup is fast (~0.1 ms), but for high-load sites cache in Redis by IP:
public function lookupCached(string $ip): array
{
return Cache::remember(
"geoip:{$ip}",
86400, // 24 hours
fn() => $this->lookup($ip)
);
}
For apps with thousands unique IPs/hour — Redis cache with 24h TTL gives noticeable boost. For typical sites — enough to store in session.
Storing in Session
// app/Http/Middleware/DetectUserRegion.php
public function handle(Request $request, Closure $next): Response
{
if (!$request->session()->has('geo')) {
$ip = app(GeoIpService::class)->getClientIp($request);
$geo = app(GeoIpService::class)->lookupCached($ip);
$request->session()->put('geo', $geo);
}
View::share('userGeo', $request->session()->get('geo'));
return $next($request);
}
After first request data in session — GEOIP database not queried again.
IPv6
MaxMind GeoLite2 supports IPv6. Only nuance — PHP filter_var handles IPv6 correctly, but isPrivateIp needs IPv6 ranges:
private function isPrivateIp(string $ip): bool
{
// Local IPv6 addresses
if (str_starts_with($ip, '::1') || str_starts_with($ip, 'fc') || str_starts_with($ip, 'fd')) {
return true;
}
return filter_var($ip, FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false;
}
Accuracy and Limitations
GeoLite2 determines city in ~75–85% for Russia users. Larger cities higher accuracy. VPN and corporate proxy users determined by exit node IP — not real location.
For critical scenarios (regional prices) consider manual region selection with cookie — user can correct detection. Solves VPN problem and matches user expectations.
Database Updates
GeoLite2 updates Tuesdays. geoipupdate in cron ensures freshness without manual intervention. On update, flush Redis cache (Cache::tags(['geoip'])->flush() if using tagged cache).







