This article is the code-level companion to the user-facing detection guide. It walks through how wpresidence-translate resolves the active language on every request, how it avoids canonical redirect loops, and which hooks it exposes for integration work on a multi-language real estate website.
Files & Entry Points
| File | Responsibility |
|---|---|
includes/language-router.php |
Request path prep, language resolution, rewrite rules, cookie persistence, canonical-redirect guards. |
includes/language-manager.php |
wpr_translate_resolve_preferred_language() — the cookie → browser → default fallback chain. |
includes/language-manager-helpers.php |
wpr_translate_match_browser_language() — Accept-Language parser and matcher. |
includes/body-language-attribute.php |
Prints the data-wpr-language attribute on <body>. |
includes/seo-tags.php |
Emits canonical and hreflang link tags; integrates Yoast SEO and Rank Math. |
Router Bootstrap
wpr_translate_language_router_bootstrap() registers the full hook set:
add_action( 'setup_theme', 'wpr_translate_language_router_prepare_request_path', 0 );
add_action( 'init', 'wpr_translate_language_router_prepare_request_path', 0 );
add_action( 'init', 'wpr_translate_language_router_register_rewrite_rules', 20 );
add_filter( 'query_vars', 'wpr_translate_language_router_register_query_vars' );
add_action( 'parse_request', 'wpr_translate_language_router_detect_language', 0 );
add_filter( 'redirect_canonical', 'wpr_translate_maybe_skip_front_page_canonical_redirect', 10, 2 );
add_filter( 'redirect_canonical', 'wpr_translate_maybe_skip_language_prefix_redirect', 2, 2 );
add_action( 'wp_ajax_wpr_translate_set_language_cookie', 'wpr_translate_handle_language_cookie_ajax' );
add_action( 'wp_ajax_nopriv_wpr_translate_set_language_cookie', 'wpr_translate_handle_language_cookie_ajax' );
Path preparation is bound to both setup_theme and init because some environments swap $_SERVER['REQUEST_URI'] between those hooks. A static $prepared flag inside the function ensures the work runs once per request.
Request Path Preparation
wpr_translate_language_router_prepare_request_path() is the first stage. It:
- Parses
$_SERVER['REQUEST_URI']into path and query pieces. - Strips the home URL’s base-path segments (subdirectory installs).
- Compares the first remaining segment against every active language’s slug and code.
- On a match, stores the language in
$GLOBALS['wpr_translate_language_router']['matched_language'], removes the prefix from the request URI, callswpr_translate_set_current_language(), and rewrites$_SERVER['REQUEST_URI']so the rest of WordPress sees the language-free path.
The function bails early when wpr_translate_language_router_should_skip_prepare() returns true — that covers admin, AJAX, CLI, and REST contexts. Both skip helpers are filterable via wpr_translate_language_router_skip_prepare and wpr_translate_language_router_skip_detection.
Query Vars & Rewrite Rules
Two public query vars are exposed:
wpr_lang— the language slug matched by the rewrite rule.wpr_lang_path— the remainder of the path after the language prefix.
Rewrite rules are added at init priority 20, after all post types have registered. For each active language:
add_rewrite_tag( '%wpr_lang%', '([^/]+)' );
add_rewrite_tag( '%wpr_lang_path%', '(.*)' );
add_rewrite_rule(
'^' . preg_quote( $slug, '/' ) . '(?:/(.*))?/?$',
'index.php?wpr_lang=' . $slug . '&wpr_lang_path=$matches[1]',
'top'
);
Rules are inserted at the top of the list so they beat core archive rules. A rewrite flush is triggered from the activator; any runtime change to the language list must call flush_rewrite_rules() explicitly.
Language Detection on parse_request
wpr_translate_language_router_detect_language( $wp ) runs at priority 0 on parse_request and takes the following branches:
- If the path prep step already matched a language, use it.
- Else if the rewrite rule populated
$wp->query_vars['wpr_lang'], resolve that viawpr_translate_get_language(), reconstruct the residual path fromwpr_lang_path, and rewrite the request URI. - Else call
wpr_translate_get_default_language(). - As a final fallback, call
wpr_translate_resolve_preferred_language()which reads the cookie then browser header.
Whichever branch wins, wpr_translate_set_current_language() is called with reload_theme_translations => true, and WPR_TRANSLATE_CURRENT_LANGUAGE is defined as a convenience constant for templates.
The Cookie Persistence Contract
wpr_translate_language_router_store_language_cookie( $code ) writes the cookie named wpestate_translation_lang_pref with:
- Expiration:
time() + MONTH_IN_SECONDS. - Path:
COOKIEPATH, mirrored toSITECOOKIEPATHwhen they differ. - Domain:
COOKIE_DOMAIN. - Secure:
is_ssl(). - HttpOnly: true.
Critical invariant: the cookie is written only when language_from_url is true. Browser-header or default-language fallbacks never overwrite an existing preference — this prevents the cookie from being destroyed by background requests for missing asset files.
AJAX Endpoint for the Switcher
The front-end language switcher hits wp-admin/admin-ajax.php?action=wpr_translate_set_language_cookie. The handler:
check_ajax_referer( 'wpr_translate_language_cookie', 'nonce' ).- Sanitises the incoming
languagewithsanitize_key(). - Confirms the language is registered and
is_active. - Calls
wpr_translate_language_router_store_language_cookie(). - Responds with
wp_send_json_success( [ 'language' => $code ] ).
Redirect-Loop Safeguards
Two redirect_canonical filters run on different priorities:
wpr_translate_maybe_skip_language_prefix_redirect(priority 2) — cancels redirects that only toggle a known language slug.wpr_translate_maybe_skip_front_page_canonical_redirect(priority 10) — when the front page is a static page, the active language is non-default, and no translation of the front page exists, the filter returnsfalseto keep the URL stable.
The front-page guard reads wpr_translation_get_translation_group() to confirm a translation does not exist before short-circuiting the redirect. If a translation is present, the canonical redirect is allowed through.
Browser Matching Algorithm
wpr_translate_match_browser_language() (in language-manager-helpers.php):
- Parses
$_SERVER['HTTP_ACCEPT_LANGUAGE'], splits on,, extracts;q=quality. - Normalises underscores to hyphens in each candidate.
arsort()by quality.- For each candidate, also tries its two-character root (so
en-GBmatches a language with codeen). - Matches against each language’s
locale(hyphen-normalised),code, andslug.
SEO Tag Emission
wpr_translate_bootstrap_seo_tags() hooks wp_head at priority 2. wpr_translate_render_seo_link_tags() pulls URLs from wpr_translate_seo_collect_urls() and emits:
<link rel="alternate" hreflang="..." href="..." />
<link rel="canonical" href="..." />
When Yoast or Rank Math are active, the same URL set is handed to their filter hooks: wpseo_hreflang_links, wpseo_canonical, rank_math/frontend/seo/hreflang, rank_math/frontend/canonical.
Body Language Attribute
wpr_translate_print_body_language_attribute() hooks wp_body_open, wp_footer, and admin_footer. It emits a tiny inline script that calls document.body.setAttribute('data-wpr-language', code). Static print-guard prevents double emission. Primarily useful for automated tests and analytics segmentation.
Settings Key
The wpr_translate_settings option carries the detect_browser_language boolean (default true, seeded by wpr_translate_activator_ensure_default_settings()). Code paths that consult it should read the option via get_option( 'wpr_translate_settings' ) rather than caching it.
Extension Points
apply_filters( 'wpr_translate_language_router_skip_prepare', $skip )— force path prep to run in CLI tests.apply_filters( 'wpr_translate_language_router_skip_detection', $skip )— same for detection.apply_filters( 'wpr_translate_language_router_force_prepare', false )— bypass the static once-per-request guard.$GLOBALS['wpr_translate_language_router']— read-only view of the parsed state if you need the current relative path without the language prefix.
Gotchas
- REST requests skip prep and detection. If you need language context in a REST handler, call
wpr_translate_get_context_language()explicitly. - The cookie is
HttpOnly— JavaScript cannot read it. Use the AJAX endpoint to change it, not direct DOM writes. - The two canonical filters sit at different priorities on purpose. Do not re-prioritise them without understanding the front-page case.
Related Reading
- URL Structure & Permalinks — outbound URL construction.
- Rewrite Rules & Query Vars — inbound request routing.
- Translation Linking (trid system) — how translation groups power the front-page guard.
Product context is on the multi-language real estate website page.