Symfony i18n Routing: Structuring Multilingual URLs Cleanly
AI generated
SF
{ }
Symfony · i18n · Routing · SEO
Symfony i18n Routing
structuring multilingual URLs cleanly

As soon as a Symfony application serves more than one language, the question comes up of how that should be reflected in the URL structure. The built-in {_locale} routing parameter covers the basic case, but it's not enough once the paths themselves also need translating, for example /de/produkte versus /en/products. Planning this structure cleanly from the start saves painful refactoring later and avoids SEO problems caused by missing or incorrect hreflang tags.

15 min read Symfony routing i18n · hreflang · SEO

1. The {_locale} routing parameter vs. subdomain-based localization

The classic approach in Symfony is the special {_locale} placeholder, usable in any route, which Symfony automatically recognizes as the current locale and carries into the request context. A route like /{_locale}/produkte means /de/produkte and /en/produkte both work, and Symfony automatically makes the locale value available for translations, number and date formatting, and URL generation via the UrlGenerator. This approach is easy to set up and works well as long as only the language code needs to vary, not the rest of the path.

Subdomain-based localization, say de.example.com and en.example.com, is the alternative, chosen mainly when different language versions need stronger technical or organizational separation, for example because different teams own different markets, or because regional hosting requirements call for physical separation. Technically this maps in Symfony onto host requirements in the routing configuration, where each subdomain can point at the same or different locale values.

2. The _locale requirement with allowed language codes

Without an explicit restriction, the {_locale} parameter would accept any value at all, including invalid language codes, which can lead to unnecessary near-misses of 404s or incorrect locale assignments. Using requirements in the route configuration, the parameter can be restricted to a fixed list of allowed codes, say de|en|fr, so requests with other values are automatically recognized as non-matching and answered with a regular 404 instead of setting an unexpected locale.

This restriction should be maintained centrally in one place, for example as a parameter in services.yaml reused across all routes, instead of rewriting the list of allowed language codes in every single route. That reduces the risk of a single route being forgotten when a new language is added and consequently reacting inconsistently to the new locale.


# config/routes.yaml
product_list:
    path: /{_locale}/{slug}
    controller: App\Controller\ProductController::list
    requirements:
        _locale: '%app.supported_locales%'
        slug: 'produkte|products'
    defaults:
        slug: produkte

3. Translated route paths with Symfony Translation

For genuinely translated paths like /de/produkte versus /en/products, the {_locale} parameter alone isn't enough, since it only varies the language code, not the rest of the path. One approach is to treat the translatable part of the path itself as a translation key, resolved via the Symfony translator inside the route definition, for example through a custom loader that reads the matching translations from translations/routes.de.yaml and translations/routes.en.yaml files while compiling the routes.

A more pragmatic approach without a custom route loader is to define a separate named route per language sharing the same controller but with a different path segment, say product_list_de with path /de/produkte and product_list_en with path /en/products. The downside is more redundancy in the routing configuration, the upside is significantly easier traceability without an extra abstraction layer, which is often the more practical choice in smaller to mid-sized projects.

4. URL generation in templates respecting the current locale

When generating URLs inside Twig templates, Symfony automatically applies the currently active locale for routes using the {_locale} parameter, so path('product_list') inside a German page automatically produces /de/produkte without the locale needing to be passed manually. For separate routes per language, as described in the previous section, the template itself has to distinguish between product_list_de and product_list_en, which is best handled through a small Twig extension function that picks the right route automatically based on the current locale.

For the language switcher, the link users click to switch between language versions of the same page, it's important not to just swap the locale in the current URL, but to actually link to the matching translated URL of the current page. That requires knowing, for every page, which route represents it in which language, which is most simply solved through a shared, language-neutral route ID that internally maps to the language-specific paths.

5. Canonical and hreflang implications for SEO

Every language version of a page needs its own canonical tag pointing at itself, not at some other language version. A common mistake is accidentally always linking to the default language, which can make search engines interpret translated versions as duplicate content of the main language and consequently index them worse or not at all.

In addition to the canonical tag, hreflang link elements in the HTML head should explicitly list every available language version of a page, including an x-default entry for users whose language isn't in the list. In Symfony these hreflang tags are best generated centrally through a Twig fragment or a view model method that automatically lists every available language version, along with its correct URL, based on the current route and known translations, instead of maintaining them manually on every page.

6. Multilingual sitemaps and robots.txt

Beyond the page structure itself, sitemaps also need to represent multilingual URLs correctly. A common practice is a sitemap index file pointing at language-specific individual sitemaps, where every url entry additionally carries xhtml:link elements for alternate language versions, mirroring the hreflang tags in the HTML head. In Symfony, such a sitemap can be generated through a dedicated controller that iterates over every known route carrying the {_locale} parameter and automatically produces one entry per supported language, instead of maintaining the sitemap structure by hand.

For robots.txt, language-specific blocking is usually unnecessary as long as canonical and hreflang tags are set correctly, since search engines then already recognize the language versions as related alternatives on their own. An exception is preview or staging subdomains for individual language versions, which should be explicitly excluded via Disallow to prevent search engines from indexing unfinished translations before they're officially published.

7. Configuring default locale and fallback behavior

Symfony lets you set a default language via default_locale in framework.yaml, used whenever no explicit locale can be determined, for example on a request to the root URL without a language prefix. It's worth designing this behavior deliberately: automatic redirection based on the browser's Accept-Language header can be user-friendly, but it should never result in the same page being reachable under multiple URLs without a clear canonical assignment.

For translation gaps, meaning pages that don't yet exist in a given language, a deliberate fallback behavior is preferable to a hard 404. Depending on the use case that can mean showing the page in the default language with a note about the missing translation, or genuinely returning a 404 if a missing translation isn't acceptable from a business standpoint, say for legally mandated content.

8. A LocaleListener for consistent locale resolution

Instead of scattering locale logic across multiple controllers, a dedicated event subscriber listening on the kernel.request event is a good fit, resolving the locale consistently in one central place and setting it on the request object. This listener can define a clear priority chain: first the {_locale} route parameter if present, then a user preference stored in the session, and only last the Accept-Language header or the configured default locale as a fallback.

This central resolution prevents different parts of the application, say an API controller and a regular web controller, from determining the locale in different and potentially conflicting ways. The listener should be registered with a sufficiently high priority so the locale is already set before other listeners or the actual controller need to access it.

9. Testing multilingual routes properly

Functional tests for multilingual routes should explicitly verify that both /de/produkte and /en/products lead to the same controller with the correctly set locale, and that a request with an unsupported language code like /xx/produkte actually returns a 404. It's also worth testing that the generated hreflang tags for a given page list exactly the language versions that actually exist, with no orphaned entries for content that hasn't been translated yet.

For projects with many languages, a data-driven test that iterates over all configured locale values and runs the same basic check for each language is preferable to writing a separate, largely redundant test case by hand for every language. That turns adding a new language into a purely configuration-level step that gets automatically covered by the existing test suite.

Approach Example URL Advantage Disadvantage
{_locale} parameter /de/produkte, /en/produkte easy to implement path itself stays the same
Translated paths /de/produkte, /en/products SEO-friendly per language more configuration effort
Subdomain de.example.com, en.example.com clear technical separation more infrastructure effort
Query parameter ?lang=de very easy to implement poor fit for SEO

Mironsoft

Symfony architecture, clean domain logic, and legacy modernization

Symfony applications that stay maintainable two years down the line?

We review existing Symfony projects for bloated controllers, missing service abstractions, and untested core logic, then build an architecture that absorbs new features without getting more fragile with every release.

Architecture Review

Checking bundle structure, dependency injection, and service abstractions for maintainability.

Legacy Modernization

Incrementally migrating outdated Symfony versions without a full rewrite.

Testing and Quality Assurance

Setting up PHPUnit, PHPStan, and CI pipelines for lasting code quality.

10. Summary

i18n Routing: Key Facts

_locale parameter

built-in Symfony mechanism for language-dependent routes

Translated paths

a separate route per language or a route loader backed by translation files

Canonical

every language version points at itself, never at the default language

Hreflang

generated centrally, including x-default for unlisted languages

11. FAQ: i18n Routing: Key Facts

1What exactly does the {_locale} parameter do in Symfony routes?
It's a special placeholder that Symfony automatically recognizes as the current locale and makes available for translations, number formatting, and URL generation as soon as it appears in a route.
2When is subdomain-based localization worth it over path-based?
Mainly when language versions need technical or organizational separation, for example due to different teams or regional hosting requirements, less so for pure SEO reasons.
3How do I restrict the _locale parameter to valid language codes?
Through requirements in the route configuration with a regex alternation like de|en|fr, ideally defined once as a reusable parameter instead of repeated in every route.
4How do I implement translated paths like /de/produkte and /en/products?
Either through separate named routes per language sharing the same controller, or through a custom route loader that resolves the path segments from translation files.
5Why does every language version need its own canonical tag?
Without its own canonical tag, search engines could classify translated pages as duplicate content of the default language and index them worse or not at all.
6What belongs in a page's hreflang tags?
One link element per available language version with the correct URL, plus an additional x-default entry for users whose language isn't explicitly listed.
7Should automatic language redirection happen based on the browser?
It can be user-friendly, but it must never result in the same page being reachable under multiple URLs without a clear canonical assignment.
8What happens when individual pages lack a translation?
Depending on the use case, either a fallback to the default language with a notice, or a deliberate 404 when a missing translation isn't acceptable from a business standpoint.
9What is a central LocaleListener recommended for?
To bundle locale resolution consistently in one place with a clear priority chain, instead of implementing it scattered across multiple controllers in potentially conflicting ways.
10How do you test multilingual routes efficiently?
With a data-driven test that iterates over all configured locale values and runs the same basic check per language, instead of writing a separate, redundant test case for each language.