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 — top ensures 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_quote of 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’] at setup_theme/init priority 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() in includes/activator.php calls flush_rewrite_rules() after creating tables and seeding defaults.
- Manual admin save — visiting Settings → Permalinks and saving triggers WordPress’s own flush, which re-runs init priority 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 for is_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_redirect at priority 2 — cancels redirects that only toggle a known language slug.
- wpr_translate_maybe_skip_front_page_canonical_redirect at 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 init priority > 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 by parse_request priority 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 of parse_request — the router unsets it.
- add_rewrite_rule() at top means 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.