Locale in a Request
Where a request's locale comes from: the URL, a listener, a stored preference, and the fallback chain behind it.
#[Route('/{_locale}/contact', requirements: ['_locale' => 'en|fr|de'])]Every request has exactly one locale. Request::getLocale() is what the translator and the Intl formatters read, so everything that renders text follows it. The question is only who sets it, and when.
From the URL
A route parameter named _locale is special: when the route matches, that value is set on the Request. A route can instead carry one path per locale, so /about-us and /over-ons are the same route with different URLs. Either way the locale is in the address, which means a page can be linked, cached and shared in one language.
From a listener
Symfony's own LocaleListener applies the route's _locale on the kernel.request event at priority 16. A listener registered above 16 can set a locale from a cookie, a stored preference or the Accept-Language header, and the URL still wins, because LocaleListener runs afterwards and overwrites it whenever the route supplied one. For the header case alone, framework.set_locale_from_accept_language: true does it with no code.
Shorthand and longhand
Listing languages in framework.enabled_locales adds the _locale requirement for you, so #[Route('/{_locale}/terms')] behaves exactly like the same route with requirements: ['_locale' => 'en|nl'] spelled out. An unlisted locale then 404s instead of reaching the controller.
The fallback chain
framework.default_locale applies when nothing sets one. translator.fallbacks lists where to look when the current catalogue has no entry for an id: fr_BE is tried, then fr, then each fallback in turn.
Example
<?php
// src/EventListener/PreferredLocaleListener.php
declare(strict_types=1);
namespace App\EventListener;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
// Symfony's LocaleListener reads the route's _locale at priority 16.
// Running at 20 means this value only survives when the URL has none,
// so a locale in the address always wins over a stored preference.
#[AsEventListener(event: KernelEvents::REQUEST, priority: 20)]
final class PreferredLocaleListener
{
/**
* @param list<string> $enabledLocales the framework.enabled_locales list
*/
public function __construct(
#[Autowire('%kernel.enabled_locales%')]
private readonly array $enabledLocales,
) {
}
public function __invoke(RequestEvent $event): void
{
// Sub-requests inherit the main request's locale; leave them alone.
if (!$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
$saved = $request->cookies->get('app_locale');
// Never trust the stored value: only accept a shipped locale.
if (\is_string($saved) && \in_array($saved, $this->enabledLocales, true)) {
$request->setLocale($saved);
return;
}
// Best Accept-Language match, restricted to the shipped locales.
$request->setLocale(
$request->getPreferredLanguage($this->enabledLocales)
?? $request->getDefaultLocale(),
);
}
}When to use it
- Serve the same pages under /en/ and /nl/ so each language has its own cacheable URL.
- Pick a first language from Accept-Language, then let a logged-out visitor override it with a cookie.
- Return 404 for a locale the application does not ship, instead of rendering untranslated text.
- Render a receipt email in the recipient's stored language from a background worker.
More examples
Locale in the route, two ways
Per-locale paths suit pages whose URL is part of the content, such as marketing and legal pages that should read naturally in each language. A {_locale} prefix suits everything else, because it scales to hundreds of routes without translating every segment. Generating a URL for one of these routes uses the current request locale by default; pass _locale explicitly to build a link into another language, as a language switcher must.
<?php
// src/Controller/AboutController.php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class AboutController extends AbstractController
{
// One route, one path per locale. The bare path at the end is used for
// any locale not listed above it. The named 'path' argument is only for
// readability here: $path is the attribute's first constructor argument
// and is typed string|array|null, so the array can also be passed
// positionally.
#[Route(path: [
'en' => '/about-us',
'nl' => '/over-ons',
'/about-us',
], name: 'app_about', methods: ['GET'])]
public function about(): Response
{
return $this->render('about/index.html.twig');
}
// Longhand: the locale is a parameter, constrained by hand.
#[Route(
path: '/{_locale}/contact',
name: 'app_contact',
requirements: ['_locale' => 'en|nl'],
methods: ['GET'],
)]
public function contact(): Response
{
return $this->render('about/contact.html.twig');
}
// Shorthand: with framework.enabled_locales set to ['en', 'nl'] the
// same requirement is added for you. Identical behaviour.
#[Route('/{_locale}/terms', name: 'app_terms', methods: ['GET'])]
public function terms(): Response
{
return $this->render('about/terms.html.twig');
}
}The configuration that backs all of this
enabled_locales is the one worth setting first: it turns an unsupported locale into a 404 at the routing layer and stops the translator building catalogues nobody asked for. set_locale_from_accept_language covers the common 'guess from the browser' case without a listener, so write the listener only when the preference comes from somewhere Symfony cannot see, such as a cookie or a database row.
# config/packages/translation.yaml
framework:
# Used when nothing else sets a locale on the request.
default_locale: 'en'
# The locales this application ships. Also becomes the automatic
# requirement for the _locale route parameter, so /de/terms 404s.
enabled_locales: ['en', 'nl']
# Set the request locale from Accept-Language when the route has no
# _locale. Off by default. The route value still takes precedence.
set_locale_from_accept_language: true
# Send a Content-Language response header matching the locale.
set_content_language_from_locale: true
translator:
default_path: '%kernel.project_dir%/translations'
# Where to look when the current catalogue has no entry for an id.
fallbacks: ['en']
# The longhand for the #[Autowire] attribute in the listener above.
# Either form wires the same argument; neither is autowired on its own,
# because a plain array has no type to match on.
services:
App\EventListener\PreferredLocaleListener:
arguments:
$enabledLocales: '%kernel.enabled_locales%'Rendering in a locale that is not the request's
This is the case the request locale cannot answer: a nightly command, a queued job, or one request that renders mail for several recipients in different languages. runWithLocale scopes the change, which matters because setLocale() on its own leaks into whatever runs next in the same process. The switch lasts for the current process only, so it is not a way to remember a user's choice between requests.
<?php
// src/Notification/InvoiceNotifier.php
declare(strict_types=1);
namespace App\Notification;
use Symfony\Component\Translation\LocaleSwitcher;
use Twig\Environment;
final class InvoiceNotifier
{
public function __construct(
private readonly LocaleSwitcher $localeSwitcher,
private readonly Environment $twig,
) {
}
/**
* @param non-empty-string $recipientLocale a locale stored per recipient
*/
public function renderBody(string $recipientLocale, int $invoiceNumber): string
{
// Switches the translator, \Locale::setDefault() and every service
// tagged kernel.locale_aware for the duration of the callback, then
// restores the previous locale even if the callback throws. The
// callback's return value is passed straight back out.
return $this->localeSwitcher->runWithLocale(
$recipientLocale,
fn (string $locale): string => $this->twig->render('email/invoice.html.twig', [
'number' => $invoiceNumber,
'locale' => $locale,
]),
);
}
}
Discussion