URL & Routing

The URL & Routing settings tab controls how PerfLocale detects a visitor’s language, constructs URLs for translated content, and behaves when a translation is missing. Get this right and everything else in the plugin becomes easy - get it wrong and you end up with duplicate content, unexpected 404s, or SEO issues.

URL structure

Four URL modes, pick one at Settings → URL & Routing → URL Mode:

  • Subdirectory (default, recommended): example.com/de/. Single domain, zero DNS changes, full SEO value concentrates on one hostname.
  • Subdomain: de.example.com. Requires DNS + SSL for each language subdomain; useful when language sites are managed by different teams.
  • Domain per language: example.de. Highest SEO weight per market (country-code TLDs); requires configuring each domain in WP multisite.
  • Query parameter: example.com/about/?lang=de. Works with every permalink structure — including Plain — and every server, since it needs no rewrite rules. The default language always keeps clean URLs (no ?lang= parameter); only non-default languages carry it. The universal-compatibility fallback for when pretty-permalink routing isn’t available.

Hide default-language prefix

When on, the default language has no prefix (example.com/about/) and only non-default languages get one (example.com/de/about/). This is the normal look for most multilingual sites - visitors in the default language see clean URLs, translations get their prefix. Turn off to force prefixes on every language (example.com/en/about/).

URL prefix format

In subdirectory and query modes the language is carried in the URL as a short identifier. Settings → URL & Routing → URL Prefix Format decides what that identifier looks like. Two choices, stored under the url_prefix_type key:

  • Language slug (slug, the default) — the short code you gave the language: example.com/de/about/.
  • Full locale (locale) — the language’s locale, lowercased with the underscore turned into a hyphen, so de_DE becomes de-de: example.com/de-de/about/.

Choose the full locale when you run more than one variant of the same language — de-de alongside de-at, en-us alongside en-gb — so the URL states the market as plainly as the hreflang tag does. Choose the slug when there is one variant per language and you want the shortest URL. The setting is global: it applies to every active language at once, not per language.

Which URL modes it affects

URL modeLanguage slugFull locale
Subdirectoryexample.com/de/about/example.com/de-de/about/
Query parameterexample.com/about/?lang=deexample.com/about/?lang=de-de
Subdomainde.example.comde.example.com (unchanged)
Domain per languageexample.deexample.de (unchanged)

Subdomain hosts are built from the language slug, and per-language domains come from the domain table you fill in under URL Mode (stored as language_domains), so neither mode is affected by this setting.

In query mode the language parameter follows this setting too, so a site set to full locale emits ?lang=de-de, not ?lang=de. The default language keeps clean URLs in query mode and carries no parameter at all, whichever form you pick.

One canonical URL per language

The configured form is the canonical one, and it is the only form PerfLocale writes. Post permalinks, term links, the language switcher, hreflang alternates and the URL converter all ask the same helper for a language’s prefix, and in subdirectory mode the rewrite rules are generated from that same value — so only the configured form has a rewrite rule at all.

Reading is deliberately more forgiving than writing. Incoming requests are matched against both forms, always: a site switched to full locale still recognises /de/ and ?lang=de, and a site switched back still recognises /de-de/ and ?lang=de-de. No bookmark, backlink or indexed URL dies because you changed a setting. Two of those readings are canonicalised on top of being understood, so the non-canonical form does not answer 200 with content the canonical URL already serves:

  • Subdirectory mode with full locale selected — a request for the slug form (/de/about/) is 301-redirected to the locale form (/de-de/about/). The handler is registered only in that configuration: on a site set to the language slug, the locale form is read but not redirected.
  • Query mode — a request whose ?lang= value is not the canonical form is 301-redirected to the one that is, in either direction. A renamed language slug is resolved through the same redirect map and canonicalised in the same step.

In both cases the language keeps one indexable URL per page, instead of two URLs answering 200 with identical content.

Both redirects are conservative by design. They fire on GET and HEAD only, so a form submission is never redirected and its body never lost, and neither runs in the admin; the subdirectory one additionally stands down for REST, XML-RPC, cron and WP-CLI requests.

The query-mode redirect carries one more guard. Two languages can want the same canonical value — a language whose slug is de-de living alongside one whose locale is de_DE — and rather than risk sending a visitor to the wrong language under a permanent, cacheable redirect, PerfLocale leaves that URL alone. Detection has already resolved it, so the visitor still gets the right content; only the extra 301 is skipped.

Changing the setting later

Saving a change to URL Prefix Format flags a rewrite-rule flush, which the next request performs, and invalidates the cached hreflang alternate sets so they do not keep pointing at the old form. Nothing needs to be rebuilt by hand and no content is touched — the prefix is derived at request time from the language row, never stored in the post. The same flush follows a change to URL Mode, the hide-default-prefix setting, Excluded Paths, the per-language domain table or Translate Slugs.

Translated slugs

Settings → Translation → Translate Slugs (translate_slugs, on by default) lets a post or term appear in URLs under its own per-language slug rather than the slug the database had to store to keep the object unique.

Terms are where this matters, because WordPress will not let two terms in a taxonomy share a slug. When PerfLocale creates a term translation it inserts the new term with the source’s base slug plus the language slug appended — tutorials becomes tutorials-de in wp_terms — and separately records a display slug for that term in that language. The display slug is what appears in URLs, so the German archive stays at /de/tutorials/ and the -de the database needed never reaches a visitor, a link or a sitemap.

The display slug PerfLocale records is the source term’s base slug — the plugin does not translate the slug itself, and it has no admin field for editing one. A genuinely translated term slug has to come from the Slug Translations dataset under Settings → Export & Import, which carries the table in and out.

How they are stored

Display slugs live in their own table, wp_perflocale_slug_translations, one row per object per language:

ColumnHolds
object_typepost or term
object_subtypeThe post type, or the taxonomy for terms
object_idThe post or term ID
language_idThe language the slug belongs to
slugThe display slug used in URLs

Two unique keys guard the table: one on (object_type, object_id, language_id), so an object has at most one display slug per language, and one on (language_id, object_type, object_subtype, slug), so two objects of the same type cannot claim the same URL in the same language. Because the second key includes the subtype, a category and a product category can both use uncategorized without conflict.

Every written slug is passed through sanitize_title(), and a slug already taken inside its own namespace is given a numeric suffix (-2, -3, and so on) rather than being rejected. Writes that genuinely fail fire the perflocale/slug/write_failed action with the reason and the object involved, so a site can log or surface them instead of discovering the gap later.

How they are applied

A stored display slug is used in three places, all of them gated on Translate Slugs — the first checks the setting as it builds each URL, and the other two are not hooked up at all while it is off:

  • Outgoing links. When PerfLocale builds a permalink or term link it swaps the object’s own slug for the display slug — matching the last path segment that equals the original slug, so an ancestor segment with the same name is left alone. An empty stored value is treated as “no translation” and the original slug is kept.
  • Incoming requests. On parse_request, a taxonomy query var carrying a display slug is resolved back to the term’s database slug before WordPress runs the query, scoped to the taxonomy so identical display slugs in different taxonomies cannot cross over. Nested archives are matched on the term’s own segment, the same reduction WordPress applies when it builds the tax query.
  • A canonical redirect. A term archive requested under its database slug is 301-redirected to the display-slug URL — again GET and HEAD only, and only for taxonomies you have marked translatable.

Posts work differently, and more simply: a post translation is a separate post, so its slug is the post’s own post_name, edited in the WordPress editor’s permalink field like any other. A new translation starts from the source post’s slug and WordPress makes it unique from there; changing it afterwards is an ordinary post edit. The slug-translations table also carries post rows when you import a site from TranslatePress, which stores per-language post slugs of its own.

Managing them

Two WP-CLI subcommands cover the housekeeping, both written up on the WP-CLI Commands page. wp perflocale slugs verify counts terms that sit in a translation group with no display slug recorded and warns if any are missing; wp perflocale slugs backfill walks every term translation group, derives the base slug from the default-language term, and records it for every member of the group. Backfill is safe to run repeatedly — existing rows are updated in place, and it reports how many writes actually landed. Term groups created before PerfLocale owned them, chiefly after a migration from another plugin, are the usual reason to run it.

Slugs are batch-loaded, not fetched one at a time. When a query returns a set of posts, PerfLocale primes their slugs for every active language in a single query, so an archive page rendering permalinks, a language switcher and hreflang tags does not turn into one lookup per post per language. Turn it off with Settings → Performance → Preload Slugs (cache_preload_slugs) if you need to.

Switching Translate Slugs off leaves the stored rows untouched but stops using them: links fall back to each object’s own slug, and the incoming resolver and the term redirect are not registered at all. Switching it back on restores the previous URLs exactly, since nothing was deleted.

Language detection order

When a visitor arrives at the root URL, PerfLocale picks a language in this order (first match wins):

  1. Language cookie from a previous visit (lifetime configurable — see Cookie Lifetime below; the cookie can also be disabled entirely under URL & Routing).
  2. GeoIP - if GeoIP Redirect is enabled, look up the visitor’s country and pick the matching language.
  3. Browser Accept-Language - parse the visitor’s browser language preferences, find the best match among active languages.
  4. Default language - fall back when nothing else matches.

Each layer can be individually disabled. Turning them all off = no auto-redirect; visitors always land on the default language until they click the switcher.

In Query mode the active language is read from the ?lang= parameter: the value is sanitized and only accepted if it matches one of your active-language slugs. A renamed slug still resolves via the redirect map (and 301s to the new form), and any unknown value falls back to the default language. Pretty permalinks are recommended for Subdirectory mode because they guarantee a URL like /de/… reaches WordPress on every server; Plain permalinks also work (/de/?p=123) when the server routes all paths to WordPress, and Query mode is the universal fallback when that isn’t guaranteed.

GeoIP Redirect

Detects the visitor’s country and sends them to the matching language. PerfLocale ships no geolocation service of its own — you supply the country lookup, either from your edge/CDN’s country header or from an API of your choosing, through the perflocale/geo/lookup_country or perflocale/geo/providers filters. Results are cached for 24 hours under a salted, anonymized hash of the network address — the IP itself is never stored — so repeat visitors don’t trigger a lookup every page load. See GeoIP Redirect for the wiring.

Excluded Paths

How a path is matched. An entry matches the path itself or anything below it, and the boundary matters: /api excludes /api and /api/v1 but not /apifoo. Both the entry and the incoming path are percent-decoded before comparison, so an entry written in your own script — /日本語/, /städte/ — matches the encoded form a browser actually sends. Either form may be pasted into the field.

URL paths that should bypass language routing entirely - useful for webhooks, XML feeds, custom REST endpoints, or anything that doesn’t have a "language" in a meaningful sense. One path per line. Matching is a plain prefix test against the site-relative request path — no wildcards — so /api/ excludes everything under /api/, and /wp-admin/ can’t accidentally match your own /my-wp-admin-guide/ page. The default list is /wp-json/, /wp-admin/ and /wp-login.php; extend it with the perflocale/excluded_paths filter if you’d rather not store the list in settings.

Missing Translation Action

When a visitor requests a page that doesn’t exist in their language, what should happen? Three modes:

  • Show content in default language - serve the source post under the requested URL. Fastest for the visitor; SEO-wise, PerfLocale wraps the content with data-nosnippet so Google doesn’t show the default-language snippet under the foreign URL.
  • Show 404 error - strictest option. No translation, no result.
  • Redirect to default language version - send the visitor to the default-language URL (302). The canonical option when you want a single canonical URL per piece of content.

Language Fallbacks

If the requested language doesn’t have the post, should PerfLocale try another language before applying the Missing Translation Action? Per-language ordered list, covered in detail at Language Fallbacks.

How long the language cookie persists (days). Default 365. Dropping to 30 or 7 is fine for sites with transient visitors; stretching beyond 365 provides no benefit since most browsers cap cookie lifetime to 1 year anyway.

← Back to Docs