This article documents the inbound routing layer of wpresidence-translate — how the plugin teaches WordPress to recognise language-prefixed URLs, which rewrite rules and query vars it registers, and where to hook in if you are wiring up a custom endpoint or a REST route on a multi-language real estate website.
The Registration Point
All inbound routing is wired up in includes/language-router.php. The relevant call chain on every request:
wpr_translate_language_router_bootstrap()
→ add_action( 'init', 'wpr_translate_language_router_register_rewrite_rules', 20 );
→ add_filter( 'query_vars', 'wpr_translate_language_router_register_query_vars' );
→ 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( 'parse_request', 'wpr_translate_language_router_detect_language', 0 );
Priority 20 on init is intentional — post-type registrations typically sit at the default priority 10, so all CPTs (including theme types like estate_property, estate_agent, estate_agency, estate_developer) are already known by the time the plugin’s rules are added.
Registered Query Vars
wpr_translate_language_router_register_query_vars() appends two public query vars:
| Query var | Purpose |
|---|---|
wpr_lang |
The language slug captured from the URL prefix. |
wpr_lang_path |
The remainder of the path after the language prefix. |
Both vars are transient — wpr_translate_language_router_detect_language() unsets them from $wp->query_vars before the main query runs so they never leak into CPT-specific query resolution.
Registered Rewrite Tags & Rules
For every active language (one iteration per entry returned by wpr_translate_get_active_languages()):
add_rewrite_tag( '%wpr_lang%', '([^/]+)' );
add_rewrite_tag( '%wpr_lang_path%', '(.*)' );
$pattern = '^' . preg_quote( $slug, '/' ) . '(?:/(.*))?/?$';
add_rewrite_rule(
$pattern,
'index.php?wpr_lang=' . $slug . '&wpr_lang_path=$matches[1]',
'top'
);
Key properties of the generated rule:
- Position —
topensures the language rule beats core archive rules such as date archives that would otherwise swallow a two-letter slug. - Optional tail —
(?:/(.*))?/?$matches/es,/es/, and/es/foo/bar/identically. - Per-language, not one-size-fits-all — a single rule per language is emitted so
preg_quoteof non-Latin slugs remains safe. - Skipped for empty slugs — languages with no slug (typically the default) are skipped; they use the unprefixed path directly.
The Two Routing Paths
The plugin actually has two ways a request is associated with a language. They converge but start differently:
- Pre-rewrite path rewrite —
wpr_translate_language_router_prepare_request_path()inspects$_SERVER['REQUEST_URI']atsetup_theme/initpriority 0, detects a language prefix, rewrites the URI to remove it, and stores the matched language in$GLOBALS['wpr_translate_language_router']['matched_language']. After this, WordPress rewrite matching sees the language-free path and resolves normally. - Rewrite-rule path — if the URI rewrite did not run (filters forced skip, plugin loaded late) but the rewrite rule fires at
parse_request,$wp->query_vars['wpr_lang']and$wp->query_vars['wpr_lang_path']are populated.wpr_translate_language_router_detect_language()then picks them up, resolves the language, and rewrites$_SERVER['REQUEST_URI']post-hoc for downstream consumers.
Having both paths keeps the plugin robust against unusual request flows (early-binding plugins, custom entry points, tests that bypass setup_theme).
Flushing
Rewrite rules are flushed at three moments:
- Activation —
wpr_translate_activate_plugin()inincludes/activator.phpcallsflush_rewrite_rules()after creating tables and seeding defaults. - Manual admin save — visiting
Settings → Permalinks and saving triggers WordPress's own flush, which re-runsinitpriority 20. - Programmatic — any extension that adds or removes a language must call
flush_rewrite_rules()after the change so the rule set reflects the new slug list.
Performance caveat: do not call flush_rewrite_rules() on every page load. The docs are emphatic on this; the plugin itself flushes only at activation.
Request URI Rewriting
wpr_translate_language_router_update_server_request_uri( $path, $query ) is the function that mutates PHP superglobals so the rest of the WordPress lifecycle sees a clean path:
$_SERVER['REQUEST_URI'] = $path . ( '' !== $query ? '?' . $query : '' );
if ( isset( $_SERVER['PATH_INFO'] ) ) {
$_SERVER['PATH_INFO'] = $path;
}
This is load-bearing for subdirectory installs and servers that surface PATH_INFO. If you fork the routing logic, keep this helper — PHP code running later (security plugins, logging) expects the post-match URI.
Skip Contexts
Two helpers gate the router:
wpr_translate_language_router_should_skip_prepare()— true foris_admin(),wp_doing_ajax(), CLI, and REST requests.wpr_translate_language_router_should_skip_detection()— same set, filterable independently.
Both results are filterable:
apply_filters( 'wpr_translate_language_router_skip_prepare', $skip );
apply_filters( 'wpr_translate_language_router_skip_detection', $skip );
Use these filters to force the router to run in integration tests that dispatch via CLI.
Interaction With redirect_canonical
Two filters guard against redirect loops (see the Detection article for detail):
wpr_translate_maybe_skip_language_prefix_redirectat priority 2 — cancels redirects that only toggle a known language slug.wpr_translate_maybe_skip_front_page_canonical_redirectat priority 10 — preserves/es/for a static front page even when the Spanish translation is absent.
Both are relevant here because canonical redirects run after rewrite matching — if you add custom rewrite rules that sit alongside the language rules, make sure your canonical logic is compatible with these two filters.
Custom Endpoints — Integration Checklist
If you add your own rewrite rule for a CPT or endpoint, there is nothing special to do in most cases — the plugin rewrites the path before your rule runs, so your pattern sees a clean URI. Specifically:
- Register your rewrite at
initpriority > 20 to run after the plugin’s rules, or at the default priority 10 to run before. Either works — the patterns do not overlap. - Do not register a rule that expects to see the language slug in the path — it has already been stripped from
$_SERVER['REQUEST_URI']by the time your rule is matched. - If you need the active language inside your endpoint, call
wpr_translate_get_current_language(); it is set byparse_requestpriority 0. - For REST endpoints, use
wpr_translate_get_context_language()— the router skips detection on REST requests by default, and this helper falls through to cookie and default.
Generating Language-Aware URLs In Reverse
The inverse of a rewrite rule is wpr_translate_language_router_get_switch_url( $language ). It reuses the parsed path, injects the target slug, and calls user_trailingslashit() to respect the site’s permalink style. Use it in widgets and programmatic language switchers rather than building URLs by string concatenation.
Gotchas
- Do not rely on
$wp->query_vars['wpr_lang']downstream ofparse_request— the router unsets it. add_rewrite_rule()attopmeans the plugin’s rules beat most others. If you have a competing two-letter CPT slug that collides with a language slug, the language wins — rename your CPT slug.- Activation flush only covers the time of activation. If you rename a language slug programmatically, call
flush_rewrite_rules()or instruct the site owner to save permalinks. - The rewrite patterns use
preg_quote( $slug, '/' )— non-Latin slugs are safe as-is, no additional encoding needed.
Related Reading
- URL Structure & Permalinks — the outbound URL builder.
- Language Detection & Redirects — the language resolution pipeline.
- WP_Query Language Filtering — how the active language is applied to queries after routing.
For product context see the multi-language real estate website page.