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.
Table of Contents
- 1. The {_locale} routing parameter vs. subdomain-based localization
- 2. The _locale requirement with allowed language codes
- 3. Translated route paths with Symfony Translation
- 4. URL generation in templates respecting the current locale
- 5. Canonical and hreflang implications for SEO
- 6. Multilingual sitemaps and robots.txt
- 7. Configuring default locale and fallback behavior
- 8. A LocaleListener for consistent locale resolution
- 9. Testing multilingual routes properly
- 10. Summary
- 11. FAQ
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