Skip to content

5. Internationalization

An internationalized application displays its text in the user’s language. The case study is in French and English. The principle is simple: the views no longer contain text, but keys ([MENU.HOME]), and a dictionary for each language associates a text with each key. The selected language is stored in a cookie.

[Symfony] includes a translation component, the Translator, which we already encountered in Example 09 for validation messages. Its dictionaries are stored in the [translations] folder, with one file per domain and per language: [validators.en.yaml] (the validation messages domain, Example 09), [messages.en.yaml] (the default domain, the one for our texts). The YAML format allows for nested keys: this is how the dictionaries for versions [ASP.NET Core MVC], [NestJS], [Spring MVC], [Flask], and [Laravel] from the case study, with the exception of syntax. For the syntax of the messages themselves, we choose the ICU format (International Components for Unicode), a standard that the [intl] extension of [PHP] can interpret, and which, for example, can inflect text into the plural form. The formatting of dates and amounts remains the same as in Example 16.

5.1. Example 22: A Bilingual Application

22-i18n/
├── config/
│   └── packages/
│       └── framework.yaml
├── src/
│   ├── Controller/
│   │   ├── LanguageController.php
│   │   └── PagesController.php
│   ├── EventListener/
│   │   └── SetLanguage.php
│   ├── Model/
│   │   └── Product.php
│   └── Twig/
│       └── FormatsExtension.php
├── templates/
│   ├── fragments/
│   │   └── navbar.html.twig
│   ├── layout.html.twig
│   └── pages/
│       ├── home.html.twig
│       └── products.html.twig
├── translations/
│   ├── messages+intl-icu.en.yaml
│   └── messages+intl-icu.fr.yaml
└── ...

5.1.1. The configuration: config/packages/framework.yaml

# config/packages/framework.yaml: the configuration of the FrameworkBundle
framework:
    secret: '%env(APP_SECRET)%'
    # the default language, and the languages supported by the application
    default_locale: en
    enabled_locales: ['en', 'fr']
    # the TRANSLATION service; its dictionaries are in the translations/ folder.
    # A key missing from the French dictionary is looked up in the English dictionary
    translator:
        default_path: '%kernel.project_dir%/translations'
        fallbacks: ['en']
  • Line 5: the application’s default language, which is used for requests that do not specify a language (Example 09);
  • line 6: the languages supported by the application. This list is used by [Symfony] (the profiler, localized route generation, etc.); our own code also has its own list, in the listener [SetLanguage];
  • lines 9–11: the translation service. [fallbacks] specifies the fallback language(s): if a key is missing from the requested language’s dictionary, it is looked up in the English dictionary. A key missing from all dictionaries is displayed as-is: this way, any omission is immediately visible on the screen.

5.1.2. The Dictionaries

The file [translations/messages+intl-icu.fr.yaml]:

# translations/messages+intl-icu.fr.yaml: the French dictionary.
# messages: the default domain; +intl-icu: the messages follow the ICU syntax
# ({name}: a parameter; {n, plural, ...}: a message that depends on a number)
MENU:
    HOME: Accueil
    PRODUCTS: Produits
HOME:
    TITLE: Bienvenue
    HELLO: 'Bonjour {name} !'
    DATE: 'Nous sommes le {date}.'
PRODUCTS:
    TITLE: Nos produits
    NAME: Produit
    PRICE: Prix
    COUNT: '{n, plural, =0 {Aucun produit} one {# produit} other {# produits}} au catalogue.'

The file name consists of three parts: the domain ([messages], the default domain), the suffix [+intl-icu], which indicates messages in the ICU format, and the language ([fr]). Keys are grouped by topic using the YAML nesting structure: the menu item “Accueil” has the key [MENU.HOME], with levels separated by a period. A text may contain parameters, written as [{name}], which are replaced during translation.

  • Line 9: Apostrophes delimit a string YAML; they are necessary when the text contains characters that YAML interprets (curly braces, colons, etc.);
  • Line 15: a plural message. [{n, plural, ...}] selects a form based on the value of the parameter [n]: [=0] for exactly 0, [one] for the singular, [other] for the plural; [#] is replaced by the number. Plural rules depend on the language, and ICU knows them: in French, 0 and 1 are singular, whereas in English, 0 is plural (“0 products”). This is an improvement over the “product(s)” used in previous versions of the case study.

The file [translations/messages+intl-icu.en.yaml] has exactly the same keys:

# translations/messages+intl-icu.en.yaml: the English dictionary; same keys
MENU:
    HOME: Home
    PRODUCTS: Products
HOME:
    TITLE: Welcome
    HELLO: 'Hello {name}!'
    DATE: 'Today is {date}.'
PRODUCTS:
    TITLE: Our products
    NAME: Product
    PRICE: Price
    COUNT: '{n, plural, =0 {No products} one {# product} other {# products}} in the catalogue.'

Without the suffix [+intl-icu], the parameters would be written as [%name%], and the plural would use a syntax specific to [Symfony] ([{0} Aucun produit|{1} Un produit|]1, Inf[ %n% produits]). The format ICU is the one currently recommended by the [Symfony] documentation.

5.1.3. The language of each query: src/EventListener/SetLanguage.php

<?php
// src/EventListener/SetLanguage.php: the language of each request

namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;

// priority 20: after the routing, before Symfony's listeners that use the language (see example 16)
#[AsEventListener(event: KernelEvents::REQUEST, priority: 20)]
class SetLanguage
{
    // the supported languages; the first one is the default language
    public const LANGUAGES = ['en', 'fr'];

    public const COOKIE = 'language';

    // the language of the [language] cookie if it is supported, English otherwise
    public function __invoke(RequestEvent $event): void
    {
        $request = $event->getRequest();
        $language = $request->cookies->get(self::COOKIE);
        $request->setLocale(in_array($language, self::LANGUAGES, true) ? $language : self::LANGUAGES[0]);
    }
}

This is the [SetLanguage] listener from Example 16, which this time retrieves the language from a cookie:

  • line 11: the [kernel.request] event listener, with priority 20: after routing, but before the [Symfony] listeners that use the request language (Example 16);
  • lines 15–17: the supported languages and the cookie name: public class constants, accessible from other classes ([SetLanguage::LANGUAGES]);
  • lines 22–24: the request language: the language of the [language] cookie if one exists and is supported; otherwise, English. [$request->cookies] is the set of cookies sent by the browser (example 05); [get] returns the value of a cookie, or [null]. [in_array], with [true] as the third parameter, also compares the types ([===]). [setLocale] sets the request language: this is the language used by the translator, our formatting filters, and the [format_currency] filter of [Twig].

Unlike [Laravel], [Symfony] does not encrypt cookies: the [language] cookie is transmitted in plain text. This is not a problem: it contains no confidential information, and the server accepts only supported languages. The following example revisits the issue of how much trust can be placed in a cookie.

5.1.4. Language selection: src/Controller/LanguageController.php

<?php
// src/Controller/LanguageController.php: the choice of the language

namespace App\Controller;

use App\EventListener\SetLanguage;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

class LanguageController extends AbstractController
{
    // GET /language/fr: stores the choice in a cookie, then goes back to the previous page.
    // requirements: a language other than en or fr does not match the route (404)
    #[Route('/language/{code}', name: 'language', requirements: ['code' => 'en|fr'], methods: ['GET'])]
    public function change(Request $request, string $code): RedirectResponse
    {
        $response = $this->redirect(self::originPage($request));
        // the cookie: one year of life, unreadable by the JavaScript of the page (httpOnly),
        // never sent with a POST request coming from another site (sameSite lax)
        $response->headers->setCookie(Cookie::create(SetLanguage::COOKIE, $code)
            ->withExpires(new \DateTimeImmutable('+1 year'))
            ->withHttpOnly(true)
            ->withSameSite(Cookie::SAMESITE_LAX));
        return $response;
    }

    // the page we come from (Referer header), of which we keep only the path:
    // redirecting to a complete address coming from the request would allow
    // sending the user to another site ("open redirect" flaw)
    private static function originPage(Request $request): string
    {
        // parse_url splits a URL into its components (path, parameters...)
        $url = parse_url($request->headers->get('Referer', ''));
        $path = $url['path'] ?? '';
        // "//site.com/..." would be understood by the browser as an address
        // on ANOTHER site: refused too
        if (! str_starts_with($path, '/') || str_starts_with($path, '//')) {
            return '/';
        }
        return isset($url['query']) ? "$path?{$url['query']}" : $path;
    }
}
  • line 17: the route [/language/{code}]. The option [requirements] (example 02) limits the parameter to the two supported languages: [/language/de] does not correspond to any route and returns a 404 error;
  • line 20: the action responds with a redirect to the page the user came from: the user is taken back to their page in the new language;
  • Lines 23–26: The response contains a cookie, which the browser will send back with every request. The [Cookie] class of [Symfony] describes it: [Cookie::create(name, value)] creates it, and each method of [with...] returns a modified copy (an immutable object; see Example 03): [withExpires], the expiration date (in one year); [withHttpOnly(true)] makes it invisible to the [JavaScript] on the page; [withSameSite(Cookie::SAMESITE_LAX)] prevents the browser from sending it with a POST request from another site. ([httpOnly] and [lax] are, incidentally, the default values for [Cookie::create].) [$response->headers->setCookie] adds it to the response as a [Set-Cookie] header;
  • lines 33–44: the original page is specified by the [Referer] header in the request (the misspelled version, with a single [r], is the one used in the HTTP standard), which the browser populates with the URL from the page that contained the link. But this header comes from the client, which can put anything in it. Blindly redirecting to this address would turn our application into an open redirect: a malicious link bearing our domain name could send the user to a malicious site. Therefore, we retain only the path and parameters from URL (the [parse_url] function from [PHP] breaks it down into its components in an associative array), and we reject any path that begins with [//], which the browser would interpret as the address of another site.

5.1.5. Actions: src/Controller/PagesController.php

<?php
// src/Controller/PagesController.php: the actions know nothing about the language:
// they provide data, the views translate and format them

namespace App\Controller;

use App\Model\Product;
use DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

#[Route(methods: ['GET'])]
class PagesController extends AbstractController
{
    #[Route('/', name: 'home')]
    public function home(): Response
    {
        return $this->render('pages/home.html.twig', ['name' => 'Marie',
            'today' => new DateTimeImmutable('2026-09-23')]);
    }

    #[Route('/products', name: 'products')]
    public function products(): Response
    {
        return $this->render('pages/products.html.twig', ['products' => [
            new Product('Keyboard', 2500),
            new Product('Mouse', 1250),
            new Product('Screen', 114900),
        ]]);
    }
}

The actions are language-agnostic: they provide raw data (a name, a date, amounts in cents). Translation and formatting are handled by the views. The class [Product] has a name and a price in cents; the extension [FormatsExtension] is the one from Example 16: its filter [long_date] writes a date in the “full” format of the query language (“Wednesday, September 23, 2026,” “mercredi 23 septembre 2026”), and its filter [money] writes an amount.

5.1.6. Translate in the views

<!doctype html>
{# app.request.locale: the language of the request, "en" or "fr" #}
<html lang="{{ app.request.locale }}">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  {# the title: the [title] block of the page, already translated #}
  <title>{% block title %}{% endblock %}</title>
  <link rel="stylesheet" href="{{ path('bootstrap', {file: 'css/bootstrap.min.css'}) }}">
  <link rel="stylesheet" href="{{ asset('css/styles.css') }}">
</head>
<body>
  <div class="container py-3">
    {% include 'fragments/navbar.html.twig' %}
    <main>
      {% block content %}{% endblock %}
    </main>
  </div>
</body>
</html>
  • line 3: the page’s [lang] attribute indicates its language to browsers, search engines, and screen readers; [app.request.locale] is the query language;
  • line 8: the page title is provided by the page itself, already translated (block [title]).
<nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
  <div class="navbar-nav me-auto">
    {# 'KEY'|trans: the translation of a key, in the language of the request #}
    <a class="nav-link" href="{{ path('home') }}">{{ 'MENU.HOME'|trans }}</a>
    <a class="nav-link" href="{{ path('products') }}">{{ 'MENU.PRODUCTS'|trans }}</a>
  </div>
  {# the current language is highlighted #}
  <div class="btn-group btn-group-sm">
    {% for code in ['fr', 'en'] %}
      <a class="btn {{ app.request.locale == code ? 'btn-primary' : 'btn-outline-primary' }}"
         href="{{ path('language', {code: code}) }}">{{ code|upper }}</a>
    {% endfor %}
  </div>
</nav>
  • lines 4–5: the filter [trans] translates a key into the language of the request;
  • lines 9–12: one button per language, which triggers the action [/language/{code}]. The current language is highlighted ([btn-primary], solid button).
{% extends 'layout.html.twig' %}
{% block title %}{{ 'HOME.TITLE'|trans }}{% endblock %}

{% block content %}
  <h1>{{ 'HOME.TITLE'|trans }}</h1>
  {# a parameter: the parameter of the trans filter, an associative array #}
  <p>{{ 'HOME.HELLO'|trans({name: name}) }}</p>
  {# the date, formatted first (long_date filter, see example 16), then passed as the [date] parameter #}
  <p>{{ 'HOME.DATE'|trans({date: today|long_date}) }}</p>
{% endblock %}
  • line 2: the translated [title] block for the template;
  • line 7: the parameters for a text are provided in an associative array, a parameter of the [trans] filter; [{name}] is replaced by the value of the key [name];
  • line 9: the date, formatted in the query language by the [long_date] filter, then passed as a parameter.
{% extends 'layout.html.twig' %}
{% block title %}{{ 'PRODUCTS.TITLE'|trans }}{% endblock %}

{% block content %}
  <h1>{{ 'PRODUCTS.TITLE'|trans }}</h1>
  <table class="table table-sm w-auto">
    <thead><tr><th>{{ 'PRODUCTS.NAME'|trans }}</th><th class="text-end">{{ 'PRODUCTS.PRICE'|trans }}</th></tr></thead>
    <tbody>
      {% for product in products %}
        <tr>
          <td>{{ product.name }}</td>
          {# the money filter of example 16: the format depends on the language #}
          <td class="text-end">{{ product.priceCents|money }}</td>
        </tr>
      {% endfor %}
    </tbody>
  </table>
  {# the plural message: ICU chooses the form according to the n parameter #}
  <p>{{ 'PRODUCTS.COUNT'|trans({n: products|length}) }}</p>
{% endblock %}
  • line 13: the filter [money], whose result depends on the language;
  • line 19: the [n] parameter for the plural form of the message: the number of products ([length], example 14). The product names themselves are not translated: they are data that, in a real application, would come from a database.

Outside of views, the translator is a service of type [TranslatorInterface], which an action or service can receive via injection: [$translator->trans('HOME.HELLO', ['name' => 'Marie'])].

5.1.7. Execution

The home page, in English (the default language), then in French after clicking the "FR" button:

Image

Image

The products page, in both languages. The amounts follow the conventions of each language:

Image

Image

The language switch, as seen with [curl]. The option [-H] provides the header [Referer] that the browser would send:

curl -i -H "Referer: http://localhost:8000/products" http://localhost:8000/language/en
1
2
3
4
5
HTTP/1.1 302 Found
...
Location: /products
...
Set-Cookie: language=en; expires=Mon, 27 Sep 2027 09:47:37 GMT; Max-Age=31536000; path=/; httponly; samesite=lax

The redirect takes you back to the original page ([Symfony] writes a relative URL, which the browser completes), and the cookie is set for one year. A [Referer] that points to another site does not redirect you there: only its path is retained.

curl -i -H "Referer: https://pirate.example/trap" http://localhost:8000/language/en
curl -i -H "Referer: http://localhost:8000//pirate.example/x" http://localhost:8000/language/fr
1
2
3
4
5
6
HTTP/1.1 302 Found
Location: /trap
...
HTTP/1.1 302 Found
Location: /
...

The page [/trap] does not exist on our site: the user will receive a 404 error, but will remain on our site. Finally, the home page, requested with the cookie [language=en] (option [-b] of [curl]):

curl -b "language=en" http://localhost:8000/
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Welcome</title>
...
        <a class="nav-link" href="/">Home</a>
    <a class="nav-link" href="/products">Products</a>
...
    <p>Hello Marie!</p>
    <p>Today is Wednesday, September 23, 2026.</p>
...

Another way to choose the language would have been to read the browser’s [Accept-Language] header (example 12; [$request->getPreferredLanguage(['fr', 'en'])] selects the best language from among those supported). The two are often combined: the browser’s language on the first visit, followed by the language the user has selected. A third method, commonly used with [Symfony], places the language in the URL ([/en/products]): a special route parameter, [{_locale}], then sets the request language itself. This case study focuses on the cookie, with English as the default.