Skip to content

5. Internationalisation

Une application internationalisée affiche ses textes dans la langue de l'utilisateur. L'étude de cas est en français et en anglais. Le principe est simple : les vues ne contiennent plus de textes, mais des clés ([MENU.ACCUEIL]), et un dictionnaire par langue associe à chaque clé son texte. La langue choisie est mémorisée dans un cookie.

[Symfony] possède un composant de traduction, le Translator, déjà rencontré à l'exemple 09 pour les messages de validation. Ses dictionnaires sont rangés dans le dossier [translations], un fichier par domaine et par langue : [validators.fr.yaml] (le domaine des messages de validation, exemple 09), [messages.fr.yaml] (le domaine par défaut, celui de nos textes). Le format YAML permet d'imbriquer les clés : c'est la présentation des dictionnaires des versions [ASP.NET Core MVC], [NestJS], [Spring MVC], [Flask] et [Laravel] de l'étude de cas, à la syntaxe près. Pour la syntaxe des messages eux-mêmes, nous choisissons le format ICU (International Components for Unicode), une norme que l'extension [intl] de [PHP] sait interpréter, et qui sait par exemple accorder un texte au pluriel. La mise en forme des dates et des montants reste celle de l'exemple 16.

5.1. Exemple 22 : une application en deux langues

22-i18n/
├── config/
│   └── packages/
│       └── framework.yaml
├── src/
│   ├── Controller/
│   │   ├── LangueController.php
│   │   └── PagesController.php
│   ├── EventListener/
│   │   └── DefinirLangue.php
│   ├── Modele/
│   │   └── Produit.php
│   └── Twig/
│       └── FormatsExtension.php
├── templates/
│   ├── fragments/
│   │   └── navbar.html.twig
│   ├── layout.html.twig
│   └── pages/
│       ├── accueil.html.twig
│       └── produits.html.twig
├── translations/
│   ├── messages+intl-icu.en.yaml
│   └── messages+intl-icu.fr.yaml
└── ...

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

# config/packages/framework.yaml : la configuration du FrameworkBundle
framework:
    secret: '%env(APP_SECRET)%'
    # la langue par défaut, et les langues gérées par l'application
    default_locale: fr
    enabled_locales: ['fr', 'en']
    # le service de TRADUCTION ; ses dictionnaires sont dans le dossier translations/.
    # Une clé absente du dictionnaire anglais est cherchée dans le dictionnaire français
    translator:
        default_path: '%kernel.project_dir%/translations'
        fallbacks: ['fr']
  • ligne 5 : la langue par défaut de l'application, celle d'une requête qui n'en précise pas (exemple 09) ;
  • ligne 6 : les langues que gère l'application. Cette liste sert à [Symfony] (le profileur, la génération des routes localisées...) ; notre propre code a aussi la sienne, dans l'écouteur [DefinirLangue] ;
  • lignes 9-11 : le service de traduction. [fallbacks] désigne la ou les langues de secours : une clé absente du dictionnaire de la langue demandée est cherchée dans le dictionnaire français. Une clé absente de tous les dictionnaires est affichée telle quelle : un oubli se voit ainsi tout de suite à l'écran.

5.1.2. Les dictionnaires

Le fichier [translations/messages+intl-icu.fr.yaml] :

# translations/messages+intl-icu.fr.yaml : le dictionnaire français.
# messages : le domaine par défaut ; +intl-icu : les messages suivent la syntaxe ICU
# ({nom} : un paramètre ; {n, plural, ...} : un message qui dépend d'un nombre)
MENU:
    ACCUEIL: Accueil
    PRODUITS: Produits
ACCUEIL:
    TITRE: Bienvenue
    BONJOUR: 'Bonjour {nom} !'
    DATE: 'Nous sommes le {date}.'
PRODUITS:
    TITRE: Nos produits
    NOM: Produit
    PRIX: Prix
    NOMBRE: '{n, plural, =0 {Aucun produit} one {# produit} other {# produits}} au catalogue.'

Le nom du fichier est fait de trois parties : le domaine ([messages], le domaine par défaut), le suffixe [+intl-icu], qui annonce des messages au format ICU, et la langue ([fr]). Les clés sont regroupées par thème grâce à l'imbrication du YAML : le texte « Accueil » du menu a pour clé [MENU.ACCUEIL], les niveaux étant séparés par un point. Un texte peut contenir des paramètres, écrits [{nom}], remplacés au moment de la traduction.

  • ligne 9 : les apostrophes délimitent une chaîne YAML ; elles sont nécessaires quand le texte contient des caractères que YAML interprète (accolades, deux-points...) ;
  • ligne 15 : un message au pluriel. [{n, plural, ...}] choisit une forme selon la valeur du paramètre [n] : [=0] pour la valeur 0 exactement, [one] pour le singulier, [other] pour le pluriel ; [#] est remplacé par le nombre. Les règles du pluriel dépendent de la langue, et ICU les connaît : en français, 0 et 1 sont au singulier, alors qu'en anglais 0 est au pluriel (« 0 products »). C'est un progrès sur le « produit(s) » des versions précédentes de l'étude de cas.

Le fichier [translations/messages+intl-icu.en.yaml] a exactement les mêmes clés :

# translations/messages+intl-icu.en.yaml : le dictionnaire anglais ; mêmes clés
MENU:
    ACCUEIL: Home
    PRODUITS: Products
ACCUEIL:
    TITRE: Welcome
    BONJOUR: 'Hello {nom}!'
    DATE: 'Today is {date}.'
PRODUITS:
    TITRE: Our products
    NOM: Product
    PRIX: Price
    NOMBRE: '{n, plural, =0 {No products} one {# product} other {# products}} in the catalogue.'

Sans le suffixe [+intl-icu], les paramètres s'écriraient [%nom%], et le pluriel avec une syntaxe propre à [Symfony] ([{0} Aucun produit|{1} Un produit|]1,Inf[ %n% produits]). Le format ICU est celui que recommande aujourd'hui la documentation de [Symfony].

5.1.3. La langue de chaque requête : src/EventListener/DefinirLangue.php

<?php
// src/EventListener/DefinirLangue.php : la langue de chaque requête

namespace App\EventListener;

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

// priorité 20 : après le routage, avant les écouteurs de Symfony qui utilisent la langue (cf. exemple 16)
#[AsEventListener(event: KernelEvents::REQUEST, priority: 20)]
class DefinirLangue
{
    // les langues gérées ; la première est la langue par défaut
    public const LANGUES = ['fr', 'en'];

    public const COOKIE = 'langue';

    // la langue du cookie [langue] si elle est gérée, le français sinon
    public function __invoke(RequestEvent $evenement): void
    {
        $requete = $evenement->getRequest();
        $langue = $requete->cookies->get(self::COOKIE);
        $requete->setLocale(in_array($langue, self::LANGUES, true) ? $langue : self::LANGUES[0]);
    }
}

C'est l'écouteur [FixerLangue] de l'exemple 16, qui prend cette fois la langue dans un cookie :

  • ligne 11 : l'écouteur de l'événement [kernel.request], avec la priorité 20 : après le routage, mais avant les écouteurs de [Symfony] qui utilisent la langue de la requête (exemple 16) ;
  • lignes 15-17 : les langues gérées, et le nom du cookie : des constantes de classe publiques, accessibles depuis les autres classes ([DefinirLangue::LANGUES]) ;
  • lignes 22-24 : la langue de la requête : celle du cookie [langue] s'il en existe un et qu'elle est gérée, le français sinon. [$requete->cookies] est l'ensemble des cookies envoyés par le navigateur (exemple 05) ; [get] rend la valeur d'un cookie, ou [null]. [in_array], avec [true] en troisième paramètre, compare aussi les types ([===]). [setLocale] fixe la langue de la requête : c'est celle qu'utilisent le traducteur, nos filtres de mise en forme, et le filtre [format_currency] de [Twig].

Contrairement à [Laravel], [Symfony] ne chiffre pas les cookies : le cookie [langue] circule en clair. Ce n'est pas un problème : il ne contient rien de secret, et le serveur n'accepte que les langues gérées. L'exemple suivant revient sur la confiance qu'on peut accorder à un cookie.

5.1.4. Le choix de la langue : src/Controller/LangueController.php

<?php
// src/Controller/LangueController.php : le choix de la langue

namespace App\Controller;

use App\EventListener\DefinirLangue;
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 LangueController extends AbstractController
{
    // GET /langue/en : mémorise le choix dans un cookie, puis revient à la page précédente.
    // requirements : une autre langue que fr ou en ne correspond pas à la route (404)
    #[Route('/langue/{code}', name: 'langue', requirements: ['code' => 'fr|en'], methods: ['GET'])]
    public function changer(Request $request, string $code): RedirectResponse
    {
        $reponse = $this->redirect(self::pageDOrigine($request));
        // le cookie : un an de vie, illisible par le JavaScript de la page (httpOnly),
        // jamais envoyé avec une requête POST venue d'un autre site (sameSite lax)
        $reponse->headers->setCookie(Cookie::create(DefinirLangue::COOKIE, $code)
            ->withExpires(new \DateTimeImmutable('+1 year'))
            ->withHttpOnly(true)
            ->withSameSite(Cookie::SAMESITE_LAX));
        return $reponse;
    }

    // la page d'où l'on vient (entête Referer), dont on ne garde que le chemin :
    // rediriger vers une adresse complète venue de la requête permettrait
    // d'envoyer l'utilisateur sur un autre site (faille "open redirect")
    private static function pageDOrigine(Request $request): string
    {
        // parse_url découpe une URL en ses composants (chemin, paramètres...)
        $url = parse_url($request->headers->get('Referer', ''));
        $chemin = $url['path'] ?? '';
        // "//site.com/..." serait compris par le navigateur comme une adresse
        // sur un AUTRE site : refusé aussi
        if (! str_starts_with($chemin, '/') || str_starts_with($chemin, '//')) {
            return '/';
        }
        return isset($url['query']) ? "$chemin?{$url['query']}" : $chemin;
    }
}
  • ligne 17 : la route [/langue/{code}]. L'option [requirements] (exemple 02) limite le paramètre aux deux langues gérées : [/langue/de] ne correspond à aucune route, et donne une erreur 404 ;
  • ligne 20 : l'action répond par une redirection vers la page d'où vient l'utilisateur : celui-ci retrouve sa page, dans la nouvelle langue ;
  • lignes 23-26 : la réponse porte un cookie, que le navigateur renverra à chaque requête. La classe [Cookie] de [Symfony] le décrit : [Cookie::create(nom, valeur)] le fabrique, et chaque méthode [with...] rend une copie modifiée (un objet immuable, cf. exemple 03) : [withExpires], la date d'expiration (dans un an) ; [withHttpOnly(true)] le rend invisible au [JavaScript] de la page ; [withSameSite(Cookie::SAMESITE_LAX)] interdit au navigateur de l'envoyer avec une requête POST venue d'un autre site. ([httpOnly] et [lax] sont d'ailleurs les valeurs par défaut de [Cookie::create].) [$reponse->headers->setCookie] l'ajoute à la réponse, sous la forme d'un entête [Set-Cookie] ;
  • lignes 33-44 : la page d'origine est donnée par l'entête [Referer] de la requête (l'orthographe fautive, avec un seul [r], est celle de la norme HTTP), que le navigateur remplit avec l'URL de la page qui contenait le lien. Mais cet entête vient du client, qui peut y mettre n'importe quoi. Rediriger aveuglément vers cette adresse ferait de notre application un redirecteur ouvert (open redirect) : un lien piégé, qui porte notre nom de domaine, pourrait envoyer l'utilisateur sur un site malveillant. On ne garde donc que le chemin et les paramètres de l'URL (la fonction [parse_url] de [PHP] la découpe en ses composants, dans un tableau associatif), et l'on refuse un chemin qui commence par [//], que le navigateur comprendrait comme l'adresse d'un autre site.

5.1.5. Les actions : src/Controller/PagesController.php

<?php
// src/Controller/PagesController.php : les actions ne savent rien de la langue :
// elles fournissent des données, les vues les traduisent et les formatent

namespace App\Controller;

use App\Modele\Produit;
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: 'accueil')]
    public function accueil(): Response
    {
        return $this->render('pages/accueil.html.twig', ['nom' => 'Marie',
            'aujourdhui' => new DateTimeImmutable('2026-09-23')]);
    }

    #[Route('/produits', name: 'produits')]
    public function produits(): Response
    {
        return $this->render('pages/produits.html.twig', ['produits' => [
            new Produit('Clavier', 2500),
            new Produit('Souris', 1250),
            new Produit('Écran', 114900),
        ]]);
    }
}

Les actions ne savent rien de la langue : elles fournissent des données brutes (un nom, une date, des montants en centimes). La traduction et la mise en forme sont l'affaire des vues. La classe [Produit] a un nom et un prix en centimes ; l'extension [FormatsExtension] est celle de l'exemple 16 : son filtre [date_longue] écrit une date au format « complet » de la langue de la requête (« mercredi 23 septembre 2026 », « Wednesday, September 23, 2026 »), et son filtre [monnaie] un montant.

5.1.6. Traduire dans les vues

<!doctype html>
{# app.request.locale : la langue de la requête, "fr" ou "en" #}
<html lang="{{ app.request.locale }}">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  {# le titre : le bloc [titre] de la page, déjà traduit #}
  <title>{% block titre %}{% endblock %}</title>
  <link rel="stylesheet" href="{{ path('bootstrap', {fichier: '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 contenu %}{% endblock %}
    </main>
  </div>
</body>
</html>
  • ligne 3 : l'attribut [lang] de la page indique sa langue aux navigateurs, aux moteurs de recherche et aux lecteurs d'écran ; [app.request.locale] est la langue de la requête ;
  • ligne 8 : le titre de la page est fourni par la page elle-même, déjà traduit (bloc [titre]).
<nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
  <div class="navbar-nav me-auto">
    {# 'CLE'|trans : la traduction d'une clé, dans la langue de la requête #}
    <a class="nav-link" href="{{ path('accueil') }}">{{ 'MENU.ACCUEIL'|trans }}</a>
    <a class="nav-link" href="{{ path('produits') }}">{{ 'MENU.PRODUITS'|trans }}</a>
  </div>
  {# la langue courante est mise en évidence #}
  <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('langue', {code: code}) }}">{{ code|upper }}</a>
    {% endfor %}
  </div>
</nav>
  • lignes 4-5 : le filtre [trans] rend la traduction d'une clé, dans la langue de la requête ;
  • lignes 9-12 : un bouton par langue, qui mène à l'action [/langue/{code}]. La langue courante est mise en évidence ([btn-primary], bouton plein).
{% extends 'layout.html.twig' %}
{% block titre %}{{ 'ACCUEIL.TITRE'|trans }}{% endblock %}

{% block contenu %}
  <h1>{{ 'ACCUEIL.TITRE'|trans }}</h1>
  {# un paramètre : le paramètre du filtre trans, un tableau associatif #}
  <p>{{ 'ACCUEIL.BONJOUR'|trans({nom: nom}) }}</p>
  {# la date, formatée d'abord (filtre date_longue, cf. exemple 16), puis passée comme paramètre [date] #}
  <p>{{ 'ACCUEIL.DATE'|trans({date: aujourdhui|date_longue}) }}</p>
{% endblock %}
  • ligne 2 : le bloc [titre], traduit, pour le gabarit ;
  • ligne 7 : les paramètres d'un texte sont donnés dans un tableau associatif, paramètre du filtre [trans] ; [{nom}] est remplacé par la valeur de la clé [nom] ;
  • ligne 9 : la date, mise en forme dans la langue de la requête par le filtre [date_longue], puis passée comme paramètre.
{% extends 'layout.html.twig' %}
{% block titre %}{{ 'PRODUITS.TITRE'|trans }}{% endblock %}

{% block contenu %}
  <h1>{{ 'PRODUITS.TITRE'|trans }}</h1>
  <table class="table table-sm w-auto">
    <thead><tr><th>{{ 'PRODUITS.NOM'|trans }}</th><th class="text-end">{{ 'PRODUITS.PRIX'|trans }}</th></tr></thead>
    <tbody>
      {% for produit in produits %}
        <tr>
          <td>{{ produit.nom }}</td>
          {# le filtre monnaie de l'exemple 16 : le format dépend de la langue #}
          <td class="text-end">{{ produit.prixCentimes|monnaie }}</td>
        </tr>
      {% endfor %}
    </tbody>
  </table>
  {# le message au pluriel : ICU choisit la forme d'après le paramètre n #}
  <p>{{ 'PRODUITS.NOMBRE'|trans({n: produits|length}) }}</p>
{% endblock %}
  • ligne 13 : le filtre [monnaie], dont le résultat dépend de la langue ;
  • ligne 19 : le paramètre [n] du message au pluriel : le nombre de produits ([length], exemple 14). Les noms des produits, eux, ne sont pas traduits : ce sont des données, qui, dans une vraie application, viendraient d'une base de données.

Hors des vues, le traducteur est un service, de type [TranslatorInterface], qu'une action ou un service peut recevoir par injection : [$traducteur->trans('ACCUEIL.BONJOUR', ['nom' => 'Marie'])].

5.1.7. Exécution

La page d'accueil, en français (la langue par défaut), puis en anglais après un clic sur le bouton « EN » :

Image

Image

La page des produits, dans les deux langues. Les montants suivent les conventions de chaque langue :

Image

Image

Le changement de langue, observé avec [curl]. L'option [-H] fournit l'entête [Referer] qu'enverrait le navigateur :

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

La redirection ramène à la page d'origine ([Symfony] écrit une adresse relative, que le navigateur complète), et le cookie est déposé pour un an. Un [Referer] qui désigne un autre site ne permet pas d'y être redirigé : seul son chemin est conservé.

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

La page [/piege] n'existe pas sur notre site : l'utilisateur obtiendra une erreur 404, mais restera chez nous. Enfin, la page d'accueil, demandée avec le cookie [langue=en] (option [-b] de [curl]) :

curl -b "langue=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="/produits">Products</a>
...
    <p>Hello Marie!</p>
    <p>Today is Wednesday, September 23, 2026.</p>
...

Une autre façon de choisir la langue aurait été de lire l'entête [Accept-Language] du navigateur (exemple 12 ; [$request->getPreferredLanguage(['fr', 'en'])] choisit la meilleure langue parmi celles qu'on gère). Les deux se combinent souvent : la langue du navigateur à la première visite, puis celle que l'utilisateur a choisie. Une troisième façon, fréquente avec [Symfony], place la langue dans l'URL ([/en/produits]) : un paramètre de route spécial, [{_locale}], fixe alors lui-même la langue de la requête. L'étude de cas s'en tient au cookie, avec le français par défaut.