---
title: "Language Fallbacks - PerfLocale"
description: "Per-language ordered fallback chains. When a translation is missing, PerfLocale walks the list and issues a single redirect to the first published translation."
canonical: "https://perflocale.com/docs/language-fallbacks/"
source: "https://perflocale.com/docs/language-fallbacks/"
format: "markdown"
---
# Language Fallbacks

When a visitor requests a page in a language that hasn’t been translated yet, PerfLocale can redirect them to a related language instead of showing the default-language fallback or a 404. Each language has its own ordered list of fallbacks, walked in priority order, and a **single redirect** is issued to the first published translation in the list - no chain of hops.

## Mental model

Configure per language at **Settings → URL & Routing → Language Fallbacks**. Each language has up to four ordered slots. Example for a site with `en_US`, `en_GB`, `en_CA`, `de_DE` (default):

```
en_US  →  [ en_GB,  en_CA,  de_DE ]
en_GB  →  [ en_US,  de_DE ]
en_CA  →  [ en_US,  en_GB,  de_DE ]
```

Visiting `/en-us/products/` when no `en_US` translation of that post exists resolves like this:

1.  Is there an `en_US` version of this content at all (you may have landed on another language’s slug under the `/en-us/` prefix)? → **single 302 to your own `en_US` URL.** The fallback chain below is consulted only when no `en_US` translation exists.
2.  Is there an `en_GB` version (first fallback)? → **Yes: single 302 to `/en-gb/products/`. Done.**
3.  Is there an `en_CA` version? - only reached if en\_GB is missing.
4.  Is there a `de_DE` version? - only reached if en\_GB and en\_CA are both missing.
5.  All missing? → fall through to the **Missing Translation Action** setting (show default / 404 / redirect to default).

## Self-contained lists

Each language’s list is the complete chain for that language. `en_US`’s fallback behaviour is **not** influenced by what `en_GB`’s own fallbacks are - setting `en_GB → [es_ES]` does not cause `en_US` visitors to land on Spanish. If you want `en_US` to try `de_DE` at the end, add `de_DE` explicitly to `en_US`’s list.

This is a deliberate design choice over the transitive-chain model used by some multilingual plugins. Self-contained lists are easier to reason about, can’t create cycles, and let each language express a distinct priority order (`en_US` might prefer `en_CA` over `en_GB`, which a shared-chain model can’t express).

## Single redirect, always

When a fallback match is found, PerfLocale **302-redirects once** straight to the target URL. Visitors never experience a chain of redirects (`/en-us/ → /en-gb/ → /de-de/`). The resolution happens server-side in a single pass before any redirect header is emitted.

The redirected URL carries a one-shot query parameter `?perflocale_fb=1` that’s stripped via a 301 canonical redirect immediately on arrival. Its job is purely to prevent re-triggering the fallback walker on the destination URL if a translation-group inconsistency would otherwise loop. You’ll never see it in the final address bar.

## SEO behaviour (301 vs 302)

Default redirect status is **302 Found** (temporary). That’s intentional: a missing translation is usually a transient state (someone’s going to translate that page eventually), and search engines should keep the unprefixed URL in their index. When the real translation ships, the 302 naturally stops firing and Google discovers the new URL.

If you’d rather send **301 Moved Permanently** - typically for sites that treat certain language pairs as permanently merged (e.g. `en_US` content will never be separately authored, always shown from `en_GB`) - use the filter:

```php
add_filter( 'perflocale/fallback/redirect_status', fn() => 301 );

// Or per-language-pair (full signature; trailing args expose the source/target post IDs
// when the fallback resolves to a specific post — 0 when it resolves to the language home):
add_filter( 'perflocale/fallback/redirect_status', function ( int $status, string $from, string $to, int $from_post_id, int $to_post_id ): int {
	if ( $from === 'en-us' && $to === 'en-gb' ) {
		return 301; // Permanent consolidation
	}
	return $status; // default 302 everywhere else
}, 10, 5 );
```

Allowed values: `301`, `302`, `307`, `308`. Anything else falls back to `302` to prevent filter mistakes from breaking the response.

## Guardrails

The feature is designed to be impossible to misconfigure into a loop or infinite redirect:

-   **Self-containment** eliminates transitive cycles by construction - each list is explicit and can’t reach beyond itself.
-   **Self-reference strip:** if a language’s list happens to include its own slug (via API / direct option edit), it’s dropped at read time.
-   **Dedupe on read:** repeated entries (e.g. `[en_GB, en_GB, de_DE]`) are collapsed preserving order.
-   **Depth cap:** lists are clipped to 10 entries. The UI only exposes 4 slots; the cap covers values written directly to `wp_options`.
-   **Sentinel fuse:** the `?perflocale_fb=1` query param on the redirect target prevents the walker from re-firing on the landing URL under any circumstances. Works even across request boundaries.
-   **Re-entry fuse:** a static flag inside the handler ensures it acts at most once per request, even if `template_redirect` fires multiple times.
-   **POST / REST / admin / CLI skip:** the walker is strictly a frontend `GET`/`HEAD` concern. Form submissions, REST calls, admin pages, and CLI never redirect.
-   **Status-code allow-list:** the filter is clamped to `{301, 302, 307, 308}`. Other values quietly revert to `302`.

## Performance

Walking the chain is **in-memory only** after the first request. Concretely:

-   One translation-group lookup fetches every translation of the current post at once.
-   Post caches are batch-primed for those candidate IDs in a single query.
-   The chain is walked with no further DB hits - publish-status checks and permalink generation come from the primed cache.
-   Per-request memo means later callers (the `redirect_default` branch, hreflang generation, language switcher) reuse the same map at zero extra cost.

With four fallback slots and eight active languages, the walk adds a single cache-backed lookup and a handful of in-memory comparisons, and only runs when the viewer actually landed on a missing-translation URL.

## Storage format & back-compat

Settings store fallbacks under the `language_fallbacks` key in PerfLocale’s `wp_options` blob. Accepted shapes:

-   **Ordered list:** `[ 'en-us' => [ 'en-gb', 'en-ca', 'de-de' ], ... ]` - each locale maps to an ordered array of fallback locales.

Only the ordered-list shape is accepted. A non-array value (such as a bare string) is dropped during sanitisation on both read and write, so the stored option always holds arrays.

## Related

-   [`perflocale/fallback/redirect_status` filter](https://perflocale.com/docs/hooks/#perflocale-fallback-redirect-status)
-   Settings → URL & Routing → _Missing Translation Action_ (show default / 404 / redirect to default)
