---
title: "GeoIP Redirect - Auto-Route Visitors by Country - PerfLocale"
description: "Send first-time visitors to their country's language by IP. No lookup service is bundled - you supply the country code, and IPs are only salted-hashed."
canonical: "https://perflocale.com/docs/geo-redirect/"
source: "https://perflocale.com/docs/geo-redirect/"
format: "markdown"
---
# GeoIP Redirect

Redirect first-time visitors to their country's language based on IP geolocation.

**No geolocation service is bundled.** PerfLocale does the routing, caching and consent handling, but it does not know where a visitor is — you tell it, through the `perflocale/geo/lookup_country` or `perflocale/geo/providers` filter. Out of the box the feature contacts nothing and no visitor IP leaves the server.

## How It Works

1.  A first-time visitor (no language cookie) arrives at the site
2.  PerfLocale detects their IP address (supports proxies, Cloudflare, load balancers)
3.  Your country source returns the visitor's country code — the `perflocale/geo/lookup_country` filter first, then whichever provider you registered and selected
4.  The country code is mapped to a language via the Country Mapping table
5.  If the mapped language is active and different from the default, a 302 redirect occurs
6.  A cookie is set to prevent future redirects

By default GeoIP redirect runs before Browser Language Redirect, so if both are enabled and GeoIP finds a match, the browser redirect is skipped. The **Redirect Priority** control on the same settings tab lets you reorder the enabled mechanisms; the first one that redirects wins.

## Supplying the country code

Earlier builds shipped a handful of IP-lookup services. They were removed: bundling them meant a plugin update could start sending visitor IPs to a third party, and the choice of vendor — with its terms, its privacy policy and its bill — is properly the site owner’s. Two seams remain.

### 1\. `perflocale/geo/lookup_country` — the one-liner

Return a two-letter code and PerfLocale uses it directly. If your CDN already resolves the country, this is all you need — and it costs no HTTP request at all:

```
// Cloudflare sets CF-IPCountry on every request when IP Geolocation is on.
add_filter( 'perflocale/geo/lookup_country', function ( string $country, string $ip ): string {
	return isset( $_SERVER['HTTP_CF_IPCOUNTRY'] )
		? sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_IPCOUNTRY'] ) )
		: $country;
}, 10, 2 );
```

The same shape works for a local MaxMind database (see the [hook reference](#perflocale-geo-lookup-country) below) or any other in-process source.

### 2\. `perflocale/geo/providers` — a registered lookup service

Register a provider with a `fetch_callback` and it appears in the **GeoIP Provider** dropdown, with a per-provider circuit breaker in front of the fetch. Use this when the lookup is an outbound API call. See the [hook reference](#perflocale-geo-providers) for the array shape.

Note that PerfLocale renders no API-key input for a registered provider — those fields went with the bundled services. If your provider declares `needs_key` and `key_setting`, populate that setting yourself; simpler still, omit `needs_key` and read your own constant or environment variable inside the callback.

## Settings

GeoIP redirect lives under **PerfLocale → Settings → URL & Routing**.

### Enable GeoIP Redirect

Checkbox to enable/disable the feature. When disabled, no lookup runs at all.

### GeoIP Provider

Lists the providers your site registered through `perflocale/geo/providers`. With none registered — the default — the field is replaced by a note saying so, since there is nothing to choose between. You can leave it that way and still use the feature via `perflocale/geo/lookup_country`.

### Custom IP Header

If your site is behind a CDN or reverse proxy that uses a custom header for the real visitor IP (e.g., Imperva, Sucuri, or a custom load balancer), use the `perflocale/geo/visitor_ip` filter to read it:

```
// Example: Imperva (Incapsula) uses X-Incap-Client-IP.
add_filter( 'perflocale/geo/visitor_ip', function ( string $ip ): string {
	if ( ! empty( $_SERVER['HTTP_X_INCAP_CLIENT_IP'] ) ) {
		return sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_INCAP_CLIENT_IP'] ) );
	}
	return $ip;
} );
```

PerfLocale automatically detects Cloudflare (`CF-Connecting-IP`), standard proxies (`X-Forwarded-For`), and nginx (`X-Real-IP`). Use this filter only if your proxy sends a non-standard header.

### Cache Duration

GeoIP results are cached via PerfLocale's object cache (the `perflocale_geo_lookup` group), falling back to a WordPress transient when no external object cache is present. The cache key is a salted, one-way hash of the visitor's anonymized network address (host bits removed) — the IP itself is never stored, and nearby visitors on the same network share one lookup. Default: 24 hours. This prevents repeated API calls and respects provider rate limits.

### Country Mapping

**Zero-config in the common case.** Once a country source is wired up, there is usually nothing to fill in here: the plugin auto-derives country codes from each language's locale (`en_US` → `US`, `de_DE` → `DE`, `fr_FR` → `FR`, etc.), so a typical multi-language site maps itself.

The Country Mapping table only matters in two cases:

1.  **Languages spoken across many countries** — Arabic (`ar`), generic Chinese (`zh`), Spanish across Latin America. The plugin can't guess your intent here, so map the country codes you want routed to that language (e.g. `SA, AE, EG, JO` for Arabic).
2.  **Custom routing overrides** — e.g. you want Australian visitors served the British English translation: enter `AU` next to `English (UK)`.

| Language | Country Codes (typical) |
| --- | --- |
| English (UK) | auto-derived: GB — or override with GB, AU, NZ |
| German | auto-derived: DE — or extend to DE, AT, CH |
| Spanish (Spain) | auto-derived: ES — or extend to ES, MX, AR, CO, CL |
| Arabic (locale ar) | Must be set explicitly: e.g. SA, AE, EG, JO, MA |

Multiple countries can map to the same language. Each country code can only map to one language — the first match wins.

#### The default language is intentionally never mappable

Visitors from countries that don't match any mapping **stay on the default automatically** — the default IS the catch-all. Mapping the default would over-redirect (e.g. visitors who explicitly chose another language). The Country Mapping table therefore hides the default-language row, and the server-side sanitiser drops any default-language entry submitted via a form, so the guarantee holds even against hand-crafted POSTs.

This works regardless of what your default language is. If your default is, say, Arabic (locale `ar`, no specific country), Arabic-speaking visitors from any country simply stay on the default Arabic version — no country code is needed for the default itself.

## Developer Hooks

### Filters

#### `perflocale/geo/lookup_country`

Return a country code from your own source (CDN header, local database, custom API). Runs first; a non-empty return short-circuits the registered provider entirely.

```php
// Use MaxMind GeoLite2 local database instead of API calls.
add_filter( 'perflocale/geo/lookup_country', function ( string $country, string $ip ): string {
	if ( ! class_exists( 'GeoIp2\Database\Reader' ) ) {
		return $country;
	}

	try {
		$reader  = new GeoIp2\Database\Reader( '/path/to/GeoLite2-Country.mmdb' );
		$record  = $reader->country( $ip );
		return $record->country->isoCode;
	} catch ( \Exception $e ) {
		return $country; // Fall through to the registered provider, if any.
	}
}, 10, 2 );
```

**Parameters:**

-   `string $country_code` - Empty string (return a 2-letter code to skip the provider lookup).
-   `string $ip` - Visitor IP address.

#### `perflocale/geo/country_code`

Modify the country code after it has been resolved, whatever the source.

```php
add_filter( 'perflocale/geo/country_code', function ( string $code, string $ip ): string {
	// Custom logic here.
	return $code;
}, 10, 2 );
```

**Parameters:**

-   `string $country_code` - Two-letter country code (uppercase).
-   `string $ip` - Visitor IP address.

#### `perflocale/geo/redirect_language`

Override which language a visitor should be redirected to, after country-to-language mapping.

```php
// Force all South American countries to Spanish.
add_filter( 'perflocale/geo/redirect_language', function ( string $slug, string $country, string $ip ): string {
	$south_america = [ 'AR', 'BO', 'BR', 'CL', 'CO', 'EC', 'GY', 'PY', 'PE', 'SR', 'UY', 'VE' ];

	if ( in_array( $country, $south_america, true ) && $country !== 'BR' ) {
		return 'es';
	}

	return $slug;
}, 10, 3 );
```

**Parameters:**

-   `string $language_slug` - Resolved language slug (or empty if no mapping found).
-   `string $country_code` - Two-letter country code.
-   `string $ip` - Visitor IP address.

#### `perflocale/geo/providers`

Register the GeoIP providers offered in the settings dropdown. The default list is empty.

```php
add_filter( 'perflocale/geo/providers', function ( array $providers ): array {
	$providers['my_provider'] = [
		'name'           => 'My GeoIP Service',
		'needs_key'      => true,
		'key_setting'    => 'geo_my_provider_key',
		'fetch_callback' => function ( string $ip, $settings ): string {
			$key = $settings->get( 'geo_my_provider_key', '' );
			$response = wp_remote_get( "https://my-api.com/lookup?ip={$ip}&key={$key}" );

			if ( is_wp_error( $response ) ) {
				return '';
			}

			$body = json_decode( wp_remote_retrieve_body( $response ), true );
			return $body['country'] ?? '';
		},
	];

	return $providers;
} );
```

`fetch_callback` receives the visitor IP and the `Settings` instance, and returns a two-letter code (or `''` on failure — repeated failures trip a per-provider circuit breaker rather than paying the HTTP timeout on every new visitor). The `geo_my_provider_key` setting above has no admin input; write it with `wp option patch update perflocale_settings geo_my_provider_key '…'`, or drop `needs_key` and read your own constant inside the callback.

**Parameters:**

-   `array $providers` - Associative array of provider definitions. Empty by default.

#### `perflocale/geo/country_map`

Filter the country-to-language mapping array.

```
// Programmatically add mappings.
add_filter( 'perflocale/geo/country_map', function ( array $map ): array {
	$map['JP'] = 'ja';
	$map['KR'] = 'ko';
	return $map;
} );
```

**Parameters:**

-   `array $map` - `[ 'US' => 'en', 'DE' => 'de', ... ]`

### Actions

#### `perflocale/geo/redirected`

Fires after a GeoIP redirect is performed. Useful for analytics or logging.

```php
add_action( 'perflocale/geo/redirected', function ( string $slug, string $country, string $ip ): void {
	error_log( "PerfLocale GeoIP: Redirected {$ip} ({$country}) to {$slug}" );
}, 10, 3 );
```

**Parameters:**

-   `string $language_slug` - Language the visitor was redirected to.
-   `string $country_code` - Detected country code.
-   `string $ip` - Visitor IP address.

## Behavior Notes

-   **Local/private IPs** (127.0.0.1, 192.168.x.x, etc.) are skipped - no lookup is made.
-   **Bots and crawlers** are excluded from redirects (same as browser redirect).
-   **Caching** keeps lookups down - one entry serves a whole anonymized network for the cache duration.
-   **Only successful lookups are cached.** An empty result writes nothing, so spoofed `X-Forwarded-For` headers can't flood `wp_options` with negative-result rows on sites without an external object cache; a failing lookup also recovers as soon as it starts working again.
-   **Cookie prevents re-redirect** - once a visitor has a language cookie, GeoIP is skipped.
-   **Consent-aware** - returning false from `perflocale/privacy/consent_given` stops the redirect before any lookup happens.
-   **Priority** (default): URL detection > Cookie > GeoIP redirect > Browser redirect > Default language. The redirect mechanisms among those — geo, browser and edge hint — can be reordered in Settings.
