PHP runs an enormous share of the web — WordPress alone is on ~40% of sites — and IP geolocation is one of the most useful enrichments you can add to a PHP application. Country-based personalization, fraud signals, regional compliance, content delivery — all of these are easier when you know where the user is.
This post walks through practical IP geolocation in PHP: starting with raw cURL, moving to the official SDK, Laravel middleware patterns, Symfony, WordPress hooks, caching, and the gotchas you’ll learn the hard way.
Minimum Viable Version
Pure PHP, no dependencies, no SDK:
$apiKey = getenv('IP2GEO_API_KEY');
$ch = curl_init('https://api.ip2geo.dev/convert?ip=8.8.8.8');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['X-Api-Key: ' . $apiKey],
]);
$response = curl_exec($ch);
curl_close($ch);
$data = json_decode($response, true);
echo $data['data']['continent']['country']['name']; // "United States"
That’s it. cURL is part of every PHP install. For one-off lookups or quick scripts, this is enough.
Using the Official SDK
composer require ip2geo-dev/sdk
use Ip2Geo\Ip2Geo;
Ip2Geo::init(getenv('IP2GEO_API_KEY'));
$result = Ip2Geo::convertIp('8.8.8.8');
if ($result['success']) {
echo $result['data']['continent']['country']['name'];
}
The SDK handles authentication, retries, batching, and (optionally) in-process caching:
Ip2Geo::init(getenv('IP2GEO_API_KEY'), [
'cache' => true,
'cache_max_size' => 1000,
'cache_ttl' => 300,
]);
For production PHP code, the SDK is the cleaner path.
Getting the Real Client IP
Default PHP code uses $_SERVER['REMOTE_ADDR'], which is the IP of whatever directly connected — usually a reverse proxy, not the user.
The right pattern:
function getClientIp(): string {
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$xff = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
return trim($xff[0]);
}
if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
return $_SERVER['HTTP_CF_CONNECTING_IP'];
}
return $_SERVER['REMOTE_ADDR'] ?? '';
}
Critical: only trust X-Forwarded-For and CF-Connecting-IP when you control the proxy or CDN that sets them. If your server is directly reachable, anyone can spoof these headers.
In Laravel, use $request->ip() after configuring TrustProxies middleware. In Symfony, use $request->getClientIp() after configuring trusted proxies. WordPress doesn’t have built-in proxy handling, so you’ll need to wire it yourself (or use a plugin).
Laravel Middleware
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Ip2Geo\Ip2Geo;
class Geolocation
{
public function __construct()
{
Ip2Geo::init(config('services.ip2geo.key'));
}
public function handle(Request $request, Closure $next)
{
$ip = $request->ip();
$geo = null;
if ($ip && !$this->isPrivateIp($ip)) {
$geo = Cache::remember("geo:{$ip}", 300, function () use ($ip) {
try {
$result = Ip2Geo::convertIp($ip);
return $result['success'] ? $result['data'] : null;
} catch (\Throwable $e) {
\Log::warning('geo lookup failed', ['ip' => $ip, 'err' => $e->getMessage()]);
return null;
}
});
}
$request->attributes->set('geo', $geo);
return $next($request);
}
private function isPrivateIp(string $ip): bool
{
return !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
}
}
Register in app/Http/Kernel.php:
protected $middleware = [
// ... other middleware ...
\App\Http\Middleware\Geolocation::class,
];
In any controller:
public function index(Request $request)
{
$geo = $request->attributes->get('geo');
$country = $geo['continent']['country']['name'] ?? 'Unknown';
return view('home', ['country' => $country]);
}
Cache::remember uses whatever cache driver you’ve configured (file, Redis, Memcached) — so this middleware is automatically distributed across all your application servers.
Symfony Subscriber
namespace App\EventSubscriber;
use Ip2Geo\Ip2Geo;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class GeolocationSubscriber implements EventSubscriberInterface
{
public function __construct(
private CacheInterface $cache,
private string $apiKey
) {
Ip2Geo::init($this->apiKey);
}
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) return;
$request = $event->getRequest();
$ip = $request->getClientIp();
if (!$ip) return;
$geo = $this->cache->get("geo_{$ip}", function (ItemInterface $item) use ($ip) {
$item->expiresAfter(300);
$result = Ip2Geo::convertIp($ip);
return $result['success'] ? $result['data'] : null;
});
$request->attributes->set('geo', $geo);
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onKernelRequest', 20],
];
}
}
WordPress Hook
WordPress doesn’t have middleware in the modern sense, but init and wp hooks work for the same purpose:
// In your theme's functions.php or a custom plugin
add_action('init', function () {
if (defined('WP_CLI') || wp_doing_cron() || is_admin()) return;
require_once __DIR__ . '/vendor/autoload.php';
\Ip2Geo\Ip2Geo::init(getenv('IP2GEO_API_KEY'));
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '';
if (str_contains($ip, ',')) {
$ip = trim(explode(',', $ip)[0]);
}
if (!$ip) return;
// Cache for 5 minutes using transients
$cacheKey = 'geo_' . md5($ip);
$geo = get_transient($cacheKey);
if ($geo === false) {
$result = \Ip2Geo\Ip2Geo::convertIp($ip);
if ($result['success']) {
$geo = $result['data'];
set_transient($cacheKey, $geo, 300);
}
}
if ($geo) {
$GLOBALS['user_geo'] = $geo;
}
});
// In templates:
function get_user_country(): string {
return $GLOBALS['user_geo']['continent']['country']['code'] ?? '';
}
WordPress transients automatically use your object cache (Redis, Memcached) if one is configured, falling back to the database otherwise.
Bulk Lookups
For analytics jobs, log processing, or anywhere you have many IPs to look up:
$ips = ['8.8.8.8', '1.1.1.1', '9.9.9.9', '64.6.64.6'];
$result = Ip2Geo::convertIps($ips);
foreach ($result['data'] as $entry) {
echo $entry['ip'] . ' → ' . $entry['continent']['country']['name'] . "\n";
}
One HTTP call for the whole batch. Much faster than serial requests.
Caching Patterns
File cache (simple)
function lookupWithFileCache(string $ip): ?array {
$cacheFile = "/tmp/geo_" . md5($ip) . ".json";
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < 300) {
return json_decode(file_get_contents($cacheFile), true);
}
$result = Ip2Geo::convertIp($ip);
if (!$result['success']) return null;
file_put_contents($cacheFile, json_encode($result['data']));
return $result['data'];
}
Redis (distributed)
$redis = new Redis();
$redis->connect('localhost');
function lookupWithRedisCache(string $ip, Redis $redis): ?array {
$cacheKey = "geo:{$ip}";
$cached = $redis->get($cacheKey);
if ($cached) {
return json_decode($cached, true);
}
$result = Ip2Geo::convertIp($ip);
if (!$result['success']) return null;
$redis->setex($cacheKey, 300, json_encode($result['data']));
return $result['data'];
}
For deeper discussion of TTLs and eviction policies, see caching strategies for IP geolocation.
Common Pitfalls
1. Trusting REMOTE_ADDR blindly
Behind a load balancer, every request appears to come from the load balancer. Configure your framework’s proxy handling (Laravel TrustProxies, Symfony trusted_proxies) or extract from forwarded headers manually.
2. Trusting forwarded headers without a proxy
The opposite mistake. If your server is directly reachable, anyone can set X-Forwarded-For to anything. Only trust it when you control the proxy.
3. Calling the API on every request
PHP’s lack of process-level memory means caching is critical. File cache, Redis, or Memcached — pick one and use it. A 5-minute cache reduces upstream API calls by 99%+ for repeat visitors.
4. Synchronous calls in user-facing requests
The API is fast (typically <50ms) but adding any synchronous network call to a hot path is a latency cost. Cache aggressively or do the lookup post-response (queue a job, enrich asynchronously).
5. Storing raw IPs forever
Under GDPR, IPs are personal data. Default to logging country/ASN, only retain raw IPs with a defined retention window.
6. Not handling failure
The SDK throws on network errors. Wrap calls in try/catch and degrade gracefully — the page should render even if the geo enrichment fails.
7. Hardcoding API keys
Use environment variables, .env files (in dev), or proper secrets management (in prod). Never commit keys.
Performance: PHP-FPM Considerations
PHP is request-scoped — each request starts fresh, including loading the SDK. To minimize per-request overhead:
- Use OPcache (you should be anyway).
- Use a persistent connection cache (Redis with persistent connections, or an object cache).
- Pre-warm the cache in your worker init if your traffic shape allows.
The per-request overhead of the SDK itself is minimal (microseconds). The network call to the API is the cost — eliminate it via caching for repeat IPs.
Production-Ready Laravel Setup
Putting it together, a real Laravel app with geolocation:
1. Install the SDK:
composer require ip2geo-dev/sdk
2. Add config in config/services.php:
'ip2geo' => [
'key' => env('IP2GEO_API_KEY'),
],
3. Set the API key in .env:
IP2GEO_API_KEY=your-api-key-here
4. Create the middleware (see Laravel example above).
5. Register it as a global middleware in app/Http/Kernel.php.
6. Use it in views and controllers:
@php($geo = $errors->any() ? null : request()->attributes->get('geo'))
@if ($geo)
<p>Hello from {{ $geo['continent']['country']['name'] }}!</p>
@endif
7. Set up caching:
Configure Redis or another cache driver in config/cache.php. The middleware automatically uses it.
8. Monitor the upstream cost: Check your API usage in the Ip2Geo dashboard. If you’re hitting limits, raise the cache TTL.
That’s a complete, production-grade implementation in ~30 minutes of work.
TL;DR
- cURL works for quick lookups; the SDK is cleaner for production.
- Always configure trusted proxies in Laravel/Symfony before using
$request->ip(). - Cache lookups — file cache, Redis, or Laravel
Cache::remember. Indispensable. - WordPress users: use
add_action('init', ...)plus transients for caching. - Batch lookups for analytics jobs with
convertIps([...]). - Handle failures gracefully — the page should render with or without geo data.
- Don’t expose API keys in repo, in client code, or in logs.
For Node.js or Python implementations, see the Node.js guide and Python guide. Same API, same response shapes — cross-stack integrations stay clean.