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:
- Is there an
en_GBversion (first fallback)? → Yes: single 302 to/en-gb/products/. Done. - Is there an
en_CAversion? - only reached if en_GB is missing. - Is there a
de_DEversion? - only reached if en_GB and en_CA are both missing. - 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:
add_filter( 'perflocale/fallback/redirect_status', fn() => 301 );
// Or per-language-pair:
add_filter( 'perflocale/fallback/redirect_status', function ( int $status, string $from, string $to ): int {
if ( $from === 'en-us' && $to === 'en-gb' ) {
return 301; // Permanent consolidation
}
return $status; // default 302 everywhere else
}, 10, 3 );
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=1query 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_redirectfires multiple times. - POST / REST / admin / CLI skip: the walker is strictly a frontend
GET/HEADconcern. 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 to302.
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_defaultbranch, 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 - sub-millisecond on a warm cache, 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 (new):
[ 'en-us' => [ 'en-gb', 'en-ca', 'de-de' ], ... ] - Flat map (legacy):
[ 'en-us' => 'en-gb', ... ]- transparently wrapped as a single-element list at read time.
No migration is forced. Sites that had fallbacks configured under the legacy UI continue to work; the next settings save writes the new shape.
Related
perflocale/fallback/redirect_statusfilter- Settings → URL & Routing → Missing Translation Action (show default / 404 / redirect to default)