PHPLaravelTutorialGeolocationAPI

IP Geolocation in Laravel and PHP: Country, Currency, and Exchange Rates

ApogeoAPI6 min read

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 GeoService singleton 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