Implementation of GeoIP-Based Currency Detection on Website
GeoIP-based currency detection is a supplement to multi-currency system that removes the need for users to manually select currency on first visit. Visitor from Belarus sees prices in BYN, from Germany in EUR, from US in USD. Implementation is simpler than it seems, but has nuances with database accuracy, caching, and respecting user choice.
How GeoIP Works
IP address → country → currency. Chain of two steps.
Step 1: IP → country. Geolocation databases used. Main options:
| Database | Type | Country Accuracy | Cost |
|---|---|---|---|
| MaxMind GeoLite2 | Local MMDB | ~95–99% | Free (registration) |
| MaxMind GeoIP2 | Local + API | ~99%+ | Paid |
| ip-api.com | HTTP API | ~98% | Free (1000/min) |
| ipinfo.io | HTTP API | ~99% | Freemium |
| DB-IP | Local | ~95% | Freemium |
For most projects MaxMind GeoLite2 is optimal: local database doesn't depend on external services and doesn't slow requests.
Step 2: country → currency. Static correspondence table ISO 3166-1 → ISO 4217.
Installing MaxMind GeoLite2
composer require geoip2/geoip2
Database updated by MaxMind every Tuesday and Friday. For automatic updates use geoipupdate utility:
# /etc/GeoIP.conf
AccountID 123456
LicenseKey your_license_key
EditionIDs GeoLite2-Country
DatabaseDirectory /var/lib/GeoIP
# cron every Wednesday and Saturday
0 3 * * 3,6 /usr/local/bin/geoipupdate
Currency Detection Service
class GeoIpCurrencyDetector
{
private Reader $geoIpReader;
public function __construct()
{
$this->geoIpReader = new Reader(
storage_path('app/geoip/GeoLite2-Country.mmdb')
);
}
public function detect(string $ip): ?string
{
// Skip private and reserved ranges
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return null;
}
try {
$record = $this->geoIpReader->country($ip);
$countryCode = $record->country->isoCode; // 'BY', 'RU', 'DE' etc.
return $this->mapCountryToCurrency($countryCode);
} catch (AddressNotFoundException) {
return null;
}
}
private function mapCountryToCurrency(string $countryCode): ?string
{
$map = config('geoip.country_currency_map');
return $map[$countryCode] ?? null;
}
}
Config country_currency_map is array of ~250 countries. Key entries:
// config/geoip.php
return [
'country_currency_map' => [
'BY' => 'BYN',
'RU' => 'RUB',
'UA' => 'UAH',
'KZ' => 'KZT',
'US' => 'USD',
'CA' => 'CAD',
'GB' => 'GBP',
'DE' => 'EUR', 'FR' => 'EUR', 'IT' => 'EUR', 'ES' => 'EUR',
'PL' => 'PLN',
'CZ' => 'CZK',
'CN' => 'CNY',
'JP' => 'JPY',
// ...and ~200 more countries
],
'fallback_currency' => 'USD',
];
Caching Results
IP-based detection is fast (local database, ~0.5 ms), but cache anyway: protection against repeated file reads on each request.
public function detectCached(string $ip): ?string
{
$cacheKey = 'geoip:' . md5($ip);
return Cache::remember($cacheKey, now()->addDay(), function () use ($ip) {
return $this->detect($ip);
});
}
TTL 24 hours — balance between freshness (user doesn't change country hourly) and accuracy (VPN switch picked up next day).
Integration in Middleware
class ResolveCurrencyFromGeoIp
{
public function handle(Request $request, Closure $next): Response
{
// If user already made explicit choice — don't override
if ($this->hasExplicitChoice($request)) {
return $next($request);
}
$ip = $request->ip();
// Account for proxies and load balancers
if ($request->header('CF-Connecting-IP')) {
$ip = $request->header('CF-Connecting-IP'); // Cloudflare
} elseif ($request->header('X-Real-IP')) {
$ip = $request->header('X-Real-IP'); // nginx proxy_pass
}
$currency = $this->detector->detectCached($ip);
if ($currency && $this->isSupportedCurrency($currency)) {
session(['auto_currency' => $currency]);
Cookie::queue('preferred_currency', $currency, 60 * 24 * 90);
}
return $next($request);
}
private function hasExplicitChoice(Request $request): bool
{
// User explicitly switched currency
return session()->has('explicit_currency_choice')
|| $request->user()?->preferred_currency;
}
}
Handling IP Behind Proxy
IP issues arise from:
- Cloudflare — real IP in
CF-Connecting-IP - nginx reverse proxy — real IP in
X-Real-IPorX-Forwarded-For - AWS ELB —
X-Forwarded-For(first in list)
Proper Laravel config via TrustProxies middleware:
// app/Http/Middleware/TrustProxies.php
protected $proxies = '*'; // or specific load balancer IPs
protected $headers = Request::HEADER_X_FORWARDED_FOR
| Request::HEADER_X_FORWARDED_HOST
| Request::HEADER_X_FORWARDED_PORT
| Request::HEADER_X_FORWARDED_PROTO;
After this $request->ip() returns correct client IP.
UX: Auto-Detection Notification
Good practice — show user toast/banner on first auto-detection:
We detected you're from Belarus. Prices shown in BYN. Change currency →
Banner shown once (sessionStorage flag) and contains quick link to currency switch. This is respect for user: automation helps, not imposes.
When GeoIP Fails
- VPN/proxy: user from RU looks like US → shown USD. Solution: "Change currency" link always available.
- Corporate networks: IP registered in different country. Same.
- Tor: exit node in random country. Fallback to default currency.
- IPv6: GeoLite2-Country supports IPv6 since 2020+. Ensure you have current version.
Testing
For local testing with fake IPs:
// In tests or local environment
if (app()->environment('local')) {
$ip = config('geoip.test_ip', '178.124.0.1'); // Belarusian IP
}
For automated tests — mock service:
$this->mock(GeoIpCurrencyDetector::class, function ($mock) {
$mock->shouldReceive('detectCached')->andReturn('BYN');
});
Timeline
- Install GeoLite2 + basic detection + country mapping: 1 day
- Middleware + caching + integration with multi-currency system: 1 day
- UX notification + respecting explicit choice: 0.5 day
- Auto-update base (cron + geoipupdate): 0.5 day
Total: 2–3 days assuming multi-currency system already implemented.







