IP Geolocation in Laravel and PHP: Country, Currency, and Exchange Rates
PHP is still responsible for a huge portion of the web — WordPress, Magento, Laravel apps. Adding IP geolocation to a Laravel project is simpler than you might think, and with a hosted API like ApogeoAPI you avoid the MaxMind setup hassle entirely.
Installation
No package needed — Laravel's Http facade wraps Guzzle. Just add your API key to .env:
APOGEOAPI_KEY=your_api_key_here
GeoService — Core Client
<?php
// app/Services/GeoService.php
namespace AppServices;
use IlluminateSupportFacadesCache;
use IlluminateSupportFacadesHttp;
use IlluminateSupportFacadesLog;
class GeoService
{
private string $apiBase = 'https://api.apogeoapi.com/v1';
private string $apiKey;
public function __construct()
{
$this->apiKey = config('services.apogeoapi.key');
}
/**
* Resolve the /24 subnet — reduces unique cache keys by ~250x for most office IPs.
*/
private function subnetKey(string $ip): string
{
$parts = explode('.', $ip);
if (count($parts) === 4) {
$parts[3] = '0';
return implode('.', $parts);
}
return $ip; // IPv6: use full IP
}
public function geolocate(string $ip): ?array
{
// Skip private/loopback IPs in development
if (in_array($ip, ['127.0.0.1', '::1']) || str_starts_with($ip, '192.168.') || str_starts_with($ip, '10.')) {
return ['countryCode' => 'US', 'countryName' => 'United States', 'currencyCode' => 'USD'];
}
$cacheKey = 'geo:' . $this->subnetKey($ip);
return Cache::remember($cacheKey, now()->addHours(4), function () use ($ip) {
$response = Http::timeout(3)
->withHeaders(['X-API-Key' => $this->apiKey])
->get("{$this->apiBase}/ip/{$ip}");
if ($response->failed()) {
Log::warning('GeoService: API call failed', ['ip' => $ip, 'status' => $response->status()]);
return null;
}
return $response->json();
});
}
public function getExchangeRate(string $currency): ?float
{
if ($currency === 'USD') return 1.0;
return Cache::remember("fx:{$currency}", now()->addHours(4), function () use ($currency) {
$response = Http::timeout(3)
->withHeaders(['X-API-Key' => $this->apiKey])
->get("{$this->apiBase}/currencies/{$currency}/rate");
return $response->successful() ? $response->json('usdRate') : null;
});
}
public function localizePrice(float $usdAmount, string $currencyCode): array
{
$rate = $this->getExchangeRate($currencyCode) ?? 1.0;
$localAmount = round($usdAmount * $rate, 2);
$symbols = [
'EUR' => '€', 'GBP' => '£', 'BRL' => 'R$', 'ARS' => '$',
'MXN' => '$', 'CLP' => '$', 'COP' => '$', 'JPY' => '¥', 'CNY' => '¥',
];
return [
'amount' => $localAmount,
'currency' => $currencyCode,
'symbol' => $symbols[$currencyCode] ?? $currencyCode . ' ',
'formatted' => ($symbols[$currencyCode] ?? $currencyCode . ' ') . number_format($localAmount, 2),
];
}
}
Register it as a singleton in AppServiceProvider:
// app/Providers/AppServiceProvider.php — inside register()
$this->app->singleton(GeoService::class);
Add the key to config/services.php:
// config/services.php
'apogeoapi' => [
'key' => env('APOGEOAPI_KEY'),
],
Middleware — Per-Request Geo Detection
<?php
// app/Http/Middleware/DetectVisitorGeo.php
namespace App\Http\Middleware;
use App\Services\GeoService;
use Closure;
use Illuminate\Http\Request;
class DetectVisitorGeo
{
public function __construct(private GeoService $geoService) {}
public function handle(Request $request, Closure $next)
{
$ip = $request->header('CF-Connecting-IP') // Cloudflare
?? $request->header('X-Forwarded-For') // Load balancer
?? $request->ip();
// Take only the first IP if comma-separated
$ip = trim(explode(',', $ip)[0]);
$geo = $this->geoService->geolocate($ip);
// Make available to all views and controllers
if ($geo) {
$request->attributes->set('visitor_geo', $geo);
view()->share('visitorCountry', $geo['countryCode'] ?? 'US');
view()->share('visitorCurrency', $geo['currencyCode'] ?? 'USD');
}
return $next($request);
}
}
Register the middleware in bootstrap/app.php (Laravel 11) or Http/Kernel.php (Laravel 10):
// Laravel 11 — bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
\App\Http\Middleware\DetectVisitorGeo::class,
]);
})
Controller Usage
<?php
// app/Http/Controllers/PricingController.php
namespace App\Http\Controllers;
use App\Services\GeoService;
use Illuminate\Http\Request;
class PricingController extends Controller
{
public function __construct(private GeoService $geoService) {}
public function index(Request $request)
{
$geo = $request->attributes->get('visitor_geo', ['countryCode' => 'US', 'currencyCode' => 'USD']);
$plans = [
['name' => 'Basic', 'usd' => 19],
['name' => 'Starter', 'usd' => 29],
['name' => 'Professional', 'usd' => 79],
];
$currency = $geo['currencyCode'];
$localizedPlans = array_map(function ($plan) use ($currency) {
return array_merge($plan, ['local' => $this->geoService->localizePrice($plan['usd'], $currency)]);
}, $plans);
return view('pricing', [
'plans' => $localizedPlans,
'country' => $geo['countryCode'],
'currency' => $currency,
]);
}
}
Blade Template
{{-- resources/views/pricing.blade.php --}}
<p class="text-sm text-gray-500">
Prices shown for {{ $country }} in {{ $currency }}
</p>
@foreach ($plans as $plan)
<div class="plan-card">
<h3>{{ $plan['name'] }}</h3>
<p class="price">
{{ $plan['local']['formatted'] }}
<span class="period">/month</span>
</p>
<p class="usd-note">USD ${{ $plan['usd'] }}</p>
</div>
@endforeach
WordPress (plain PHP)
If you're using WordPress without Laravel, here's a minimal function to add to your theme's functions.php:
<?php
function apogeo_get_visitor_country(): string {
$ip = $_SERVER['HTTP_CF_CONNECTING_IP']
?? $_SERVER['HTTP_X_FORWARDED_FOR']
?? $_SERVER['REMOTE_ADDR'] ?? '';
$ip = trim(explode(',', $ip)[0]);
$cache = get_transient('apogeo_geo_' . md5($ip));
if ($cache) return $cache;
$response = wp_remote_get(
"https://api.apogeoapi.com/v1/ip/{$ip}",
['headers' => ['X-API-Key' => defined('APOGEOAPI_KEY') ? APOGEOAPI_KEY : '']]
);
if (is_wp_error($response)) return 'US';
$body = json_decode(wp_remote_retrieve_body($response), true);
$country = $body['countryCode'] ?? 'US';
set_transient('apogeo_geo_' . md5($ip), $country, 4 * HOUR_IN_SECONDS);
return $country;
}
Testing Locally
In development, $request->ip() returns 127.0.0.1. Use Tinker to test the service directly:
php artisan tinker
>>> app(App\Services\GeoService::class)->geolocate('8.8.8.8')
# => ['countryCode' => 'US', 'countryName' => 'United States', 'currencyCode' => 'USD', ...]
Or override the IP in your controller during development:
$testIp = app()->environment('local') ? '200.45.67.89' : $request->ip();
$geo = $this->geoService->geolocate($testIp);
Cache Configuration
Laravel defaults to the file cache driver. For production, use Redis:
CACHE_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=your_redis_password
REDIS_PORT=6379
This makes GeoService::geolocate() share the geo cache across all web workers — important when running multiple PHP-FPM workers or a queue.
Summary
- One
GeoServicesingleton handles both IP lookup and exchange rates - Middleware injects country/currency into every request and all Blade views
- Cache at the /24 subnet level — reduces API calls 50–250x vs per-IP caching
- Graceful fallback to USD/US if the API is unreachable
API docs: api.apogeoapi.com/api/docs — Free tier at app.apogeoapi.com/register (no credit card).
Try ApogeoAPI free
1,000 requests/month forever. 14-day full-access trial. No credit card.
Get your free API key