Skip to content

Symfony Translation — i18n industriel

TL;DRsymfony/translation est le composant qui transforme votre app monolingue en plate-forme multi-locales. Vous écrivez $translator->trans('user.welcome', ['%name%' => $name]), et il pioche dans des catalogues (XLIFF / YAML / PHP) selon la locale courante. Avec ICU MessageFormat, vous gérez pluriels (un cas par règle CLDR), genre, ordinaux, formats nombres/dates correctement. La locale se détermine via route prefix, header, session, ou détection custom. En CI, bin/console translation:extract + lint:xliff garantissent qu'aucune clé n'est manquante. Le frontend JS récupère les mêmes traductions via JSON exporté ou bazinga-jstranslation.

🧠 Mental model — ASCII + analogie

L'i18n est un dictionnaire géant indexé par locale. Vous ne savez pas (au moment du code) dans quelle langue le user lit ; vous codez avec une clé symbolique (user.welcome), et le composant traduit. Le pluriel anglais ("1 file" / "2 files") n'a rien à voir avec le pluriel arabe (qui a 6 catégories) ou russe (3). ICU MessageFormat encode ces règles via CLDR.

┌────────────────────────────────────────────────────────────────┐
│                    Application                                  │
│                                                                  │
│   {{ 'user.welcome'|trans({'%name%': user.name}) }}              │
└─────────────────────────────┬──────────────────────────────────┘


              ┌─────────────────────────────────┐
              │       Translator (service)       │
              └─────────────┬───────────────────┘

       ┌────────────────────┼─────────────────────┐
       ▼                    ▼                     ▼
  ┌─────────┐         ┌─────────┐           ┌──────────────┐
  │  locale │         │ domain  │           │   catalogue  │
  │ courante│         │(default,│           │   resolver   │
  │  (fr/en)│         │ admin…) │           │  (loader)    │
  └─────────┘         └─────────┘           └──────┬───────┘


                          ┌─────────────────────────────────────┐
                          │  translations/messages.fr.xliff      │
                          │  translations/messages.en.xliff      │
                          │  translations/admin.fr.yaml          │
                          │  translations/admin.en.yaml          │
                          └─────────────────────────────────────┘

Trois concepts à séparer mentalement :

  • Locale : fr, en, fr_BE, en_GB. Géolinguistique.
  • Domain : sous-fichier de catalogue. Par défaut messages. Utiliser admin, forms, emails pour modulariser.
  • Loader : qui sait lire un fichier (XliffFileLoader, YamlFileLoader, PhpFileLoader, CsvFileLoader).

🛠️ Code minimal (PHP 8.2+)

Installation et config de base

bash
composer require symfony/translation
# Pour ICU MessageFormat avancé (recommandé pour pluriels propres)
# php-intl doit être installé côté système
yaml
# config/packages/translation.yaml
framework:
    default_locale: 'fr'
    translator:
        default_path: '%kernel.project_dir%/translations'
        enabled_locales: ['fr', 'en', 'es']
        providers: ~ # Crowdin/Lokalise/PoEditor configurable ici
        fallbacks: ['en']

Format ICU MessageFormat (recommandé)

Fichier translations/messages+intl-icu.fr.xliff (le suffixe +intl-icu active le formateur ICU) :

xml
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="fr">
  <file id="messages.fr">
    <unit id="user.welcome">
      <segment>
        <source>user.welcome</source>
        <target>Bonjour {name}, content de te revoir !</target>
      </segment>
    </unit>
    <unit id="cart.items">
      <segment>
        <source>cart.items</source>
        <target>{count, plural,
          =0    {Votre panier est vide.}
          one   {Vous avez # article dans votre panier.}
          other {Vous avez # articles dans votre panier.}
        }</target>
      </segment>
    </unit>
    <unit id="rank.position">
      <segment>
        <source>rank.position</source>
        <target>Vous êtes {place, selectordinal,
          one   {#er}
          two   {#e}
          few   {#e}
          other {#e}
        } du classement.</target>
      </segment>
    </unit>
    <unit id="invite.greeting">
      <segment>
        <source>invite.greeting</source>
        <target>{gender, select,
          female {Madame {name}}
          male   {Monsieur {name}}
          other  {Bonjour {name}}
        }, bienvenue.</target>
      </segment>
    </unit>
  </file>
</xliff>

Et le pendant anglais translations/messages+intl-icu.en.xliff :

xml
<unit id="cart.items">
  <segment>
    <source>cart.items</source>
    <target>{count, plural,
      =0    {Your cart is empty.}
      one   {You have # item in your cart.}
      other {You have # items in your cart.}
    }</target>
  </segment>
</unit>

Différence clé : avec ICU, le count est passé comme paramètre numérique, et c'est le format qui choisit le cas. Pas besoin d'appeler une méthode spéciale comme transChoice() (supprimée en Symfony 5.0).

Usage en PHP

php
<?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;
use Symfony\Contracts\Translation\TranslatorInterface;

final class CartController extends AbstractController
{
    public function __construct(private readonly TranslatorInterface $translator) {}

    #[Route('/cart', name: 'cart_show')]
    public function show(): Response
    {
        $count = 3;
        $msg = $this->translator->trans('cart.items', ['count' => $count]);

        return new Response($msg); // "Vous avez 3 articles dans votre panier."
    }
}

Usage en Twig

twig
{# templates/cart/show.html.twig #}

<h1>{{ 'user.welcome'|trans({ name: user.firstName }) }}</h1>

<p>{{ 'cart.items'|trans({ count: cart.itemsCount }) }}</p>

{# Avec un domaine custom #}
<p>{{ 'admin.dashboard.title'|trans({}, 'admin') }}</p>

{# Locale forcée pour un bloc #}
{% trans_default_domain 'emails' %}
<p>{{ 'header.greeting'|trans }}</p>

Détection de la locale

yaml
# config/packages/framework.yaml
framework:
    default_locale: 'fr'
    set_locale_from_accept_language: true # depuis Symfony 6.4
    set_content_language_from_locale: true

Le LocaleListener lit automatiquement :

  1. Préfixe de route _locale (/fr/..., /en/...)
  2. Attribut de requête _locale
  3. Header Accept-Language (si activé)
  4. defaultLocale en fallback

Routes localisées

php
#[Route(
    path: [
        'fr' => '/articles/{slug}',
        'en' => '/articles/{slug}',
        'es' => '/articulos/{slug}',
    ],
    name: 'article_show',
)]
public function show(string $slug, string $_locale): Response { /* ... */ }

Symfony route selon le path et injecte $_locale. URLs traduites natives — bon pour SEO multi-pays.

Locale via session pour les users connectés

php
<?php

namespace App\EventListener;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;

final class UserLocaleSubscriber implements EventSubscriberInterface
{
    public function onKernelRequest(RequestEvent $event): void
    {
        $request = $event->getRequest();
        $user = $event->getRequest()->getSession()->get('_security_main')?->getUser();

        if ($user !== null && method_exists($user, 'getPreferredLocale')) {
            $request->setLocale($user->getPreferredLocale() ?? 'fr');
        }
    }

    public static function getSubscribedEvents(): array
    {
        // Doit s'exécuter APRÈS LocaleListener (priorité < 16)
        return [KernelEvents::REQUEST => [['onKernelRequest', 15]]];
    }
}

🎯 Patterns courants — 5

1. Domain organization

Séparez les catalogues par contexte :

translations/
├── messages+intl-icu.fr.xliff       # UI public
├── messages+intl-icu.en.xliff
├── admin+intl-icu.fr.xliff          # backoffice
├── admin+intl-icu.en.xliff
├── emails+intl-icu.fr.xliff         # corps d'email
├── emails+intl-icu.en.xliff
├── forms+intl-icu.fr.xliff          # labels/erreurs formulaires
├── validators+intl-icu.fr.xliff     # messages Validator (`validators` est le domaine de Validator par défaut)
└── security+intl-icu.fr.xliff       # messages Security ("Invalid credentials.")

Bénéfices : diff Git plus lisible, droits d'édition séparables (un traducteur ne touche pas l'admin), lazy loading possible si vous êtes paranoïaque sur la mémoire.

2. Extraction automatique avec translation:extract

bash
# Scanne le code et ajoute les clés manquantes au catalogue fr (sans écraser)
php bin/console translation:extract fr --force --format=xlf12 --domain=messages

# Mode "dry run" : juste lister les clés manquantes
php bin/console translation:extract fr --dump-messages

# Pour un nouveau domain
php bin/console translation:extract fr --force --domain=admin --output-format=xlf20

À mettre dans le pipeline :

  • Pre-commit hook : translation:extract puis git diff --exit-code → bloque si des clés manquent.
  • CI : lint:xliff translations/ → vérifie la syntaxe XLIFF.
bash
# Validation XLIFF en CI
php bin/console lint:xliff translations/
php bin/console lint:yaml translations/

3. Gestion des clés manquantes en CI

yaml
# config/packages/test/translation.yaml
framework:
    translator:
        # En test, on veut détecter les MISS tôt : on active le logging des
        # clés manquantes (« user.welcom » mal orthographié, etc.) et la CI
        # transforme ces logs/diagnostics en échec (voir le script plus bas).
        logging: true

Le translator ne lève jamais d'exception sur une clé manquante (le MISS est silencieux par design — il retourne l'id brut). Pour un fail-fast, on ne s'appuie donc pas sur un flag de config runtime mais sur le LoggingTranslator (qui logge les misses, déjà branché en dev) couplé à un garde-fou CI : debug:translation <locale> --only-missing. En prod, on log sans faire échouer la requête (UX > correctitude).

yaml
# config/packages/dev/translation.yaml
framework:
    translator:
        logging: true

Et pour bloquer la CI :

bash
# Script CI custom
php bin/console debug:translation fr --only-missing
test $? -eq 0 || exit 1

4. Synchronisation frontend (JS)

Deux approches :

A. Export JSON à la build

php
<?php

namespace App\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Translation\MessageCatalogueInterface;
use Symfony\Component\Translation\Translator;

#[AsCommand(name: 'app:translations:export-json')]
final class ExportTranslationsCommand extends Command
{
    public function __construct(private readonly Translator $translator) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        foreach (['fr', 'en', 'es'] as $locale) {
            /** @var MessageCatalogueInterface $catalogue */
            $catalogue = $this->translator->getCatalogue($locale);
            $messages = $catalogue->all('messages');
            file_put_contents(
                "public/i18n/messages.{$locale}.json",
                json_encode($messages, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT),
            );
        }
        return Command::SUCCESS;
    }
}
bash
# À la build (ou dans le post-deploy)
php bin/console app:translations:export-json

Le frontend charge le JSON et utilise une lib comme intl-messageformat (la même grammaire ICU que côté PHP — cohérence garantie).

B. bazinga-jstranslation-bundle

Bundle historique qui expose une route /translations.js :

bash
composer require willdurand/js-translation-bundle
yaml
# config/routes.yaml
bazinga_js_translation:
    resource: "@BazingaJsTranslationBundle/Resources/config/routing/routing.yml"
html
<script src="{{ asset('bundles/bazingajstranslation/js/translator.min.js') }}"></script>
<script src="{{ path('bazinga_jstranslation_js', { domains: 'messages', locales: 'fr,en' }) }}"></script>
<script>
    console.log(Translator.trans('cart.items', { count: 3 })); // "Vous avez 3 articles dans votre panier."
</script>

Plus simple, mais ajoute des dépendances JS legacy. Pour les SPA modernes (React/Vue/Svelte), préférez l'approche JSON + intl-messageformat.

5. Provider externe (Crowdin / Lokalise) — push/pull

yaml
# config/packages/translation.yaml
framework:
    translator:
        providers:
            crowdin:
                dsn: '%env(CROWDIN_DSN)%'
                locales: ['fr', 'en', 'es']
                domains: ['messages', 'admin']
bash
# Envoie les catalogues locaux vers Crowdin
php bin/console translation:push crowdin --force

# Récupère les traductions terminées
php bin/console translation:pull crowdin --force --domains=messages

Le pattern recommandé : fr (source) en local + Git ; les autres locales sont gérées dans Crowdin et pull se fait avant la build.

🔄 Versions — Symfony 5.4 / 6.4 / 7.x

VersionApport principal
5.4 (LTS)ICU MessageFormat avec suffixe +intl-icu mature. translation:extract mature. (transChoice() était déprécié en 4.2 et déjà supprimé en 5.0 — on raisonne 100 % ICU plurals.)
6.0Nettoyage des dernières deprecations 5.x du composant.
6.2+translation:push/pull pour providers externes (Crowdin, Lokalise, Loco, PoEditor).
6.4 (LTS)set_locale_from_accept_language config option (avant : custom listener). LocaleSwitcher pour swap temporaire.
7.0API plus stricte, suppression deprecations.
7.1+MessageCatalogue perf improvements ; meilleurs messages d'erreur quand ICU plante.
7.2+translation:lint standalone qui valide ICU syntax au-delà de XLIFF schema.

⚠️ Pitfalls — 10

  1. Oublier le suffixe +intl-icu. Sans lui, le formatter par défaut (legacy) ne comprend pas {count, plural, ...}. Soit vous mettez le suffixe partout, soit jamais (sinon comportements mixtes incompréhensibles).

  2. Pluriels français vs anglais. EN a 2 cas (one, other), FR a aussi one + other, mais avec règle différente : one ≡ 0 ou 1 en FR (0 fichier, 1 fichier), other ≡ ≥ 2. Ne jamais copier-coller des clés EN sans vérifier la règle CLDR cible. Test : 1.5 en FR → one (oui, vraiment), en EN → other.

  3. Clés en langue source ('Hello %name%') au lieu de clés symboliques ('user.welcome'). En cas de retouche FR, vous modifiez la clé partout dans le code. Privilégiez toujours dotted keys symboliques.

  4. %name% vs {name}. Le format historique %name% marche avec le formatter legacy ; ICU veut {name}. Ne mélangez pas. Avec ICU, c'est toujours {name}.

  5. Locale fr_FR vs fr. Le composant fallback : fr_FRfren (selon fallbacks). Mais si vous avez les deux fichiers, le plus spécifique gagne. Cohérence : choisissez une convention dans tout le projet.

  6. Caches qui ne se rafraîchissent pas en prod. Après un pull de provider, vider var/cache/prod/translations/ est obligatoire (ou redéployer).

  7. HTML dans les messages. Marqueur direct = injection si var non échappée. Twig échappe par défaut, mais soyez vigilants avec raw filter. Mieux : structurer le HTML hors du message — un filtre trans retourne du texte, à composer avec du markup côté template :

    twig
    {# OK : le HTML reste dans le template, la traduction est pure prose #}
    <strong>{{ 'user.welcome'|trans({ name: user.name }) }}</strong>
    
    {# DANGER : du HTML dans le catalogue + |raw = vecteur XSS si name contient des balises #}
    <p>{{ 'user.welcome_html'|trans({ '%name%': user.name })|raw }}</p>

    Pour injecter un lien au milieu d'une phrase traduite (le « translator's nightmare »), passez le HTML autour de fragments traduits, ou utilisez un placeholder ICU et reconstruisez côté template — jamais de balises dans le catalogue couplées à |raw sur une variable utilisateur.

  8. Validator domain. Les messages d'erreurs @Assert\NotBlank(message: '...') cherchent dans le domain validators (pas messages). Idem security.

  9. JS désynchronisé. Si votre app PHP a cart.items en +intl-icu mais que le JSON exporté ne respecte pas l'ICU, le frontend cassera. Soit tout le monde parle ICU, soit personne.

  10. Tests unitaires sans framework.translator.fallbacks. Un test qui trans une clé absente dans fr mais présente en en peut passer en local et casser en CI si la config diffère.

🧪 Testing — phpunit + KernelTestCase

Test de traduction unitaire

php
<?php

namespace App\Tests\Translation;

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Contracts\Translation\TranslatorInterface;

final class CartMessagesTest extends KernelTestCase
{
    private TranslatorInterface $translator;

    protected function setUp(): void
    {
        self::bootKernel();
        $this->translator = static::getContainer()->get(TranslatorInterface::class);
    }

    public function testCartEmptyMessageFrench(): void
    {
        $msg = $this->translator->trans('cart.items', ['count' => 0], 'messages', 'fr');
        self::assertSame('Votre panier est vide.', $msg);
    }

    public function testCartPluralFrench(): void
    {
        self::assertSame(
            'Vous avez 1 article dans votre panier.',
            $this->translator->trans('cart.items', ['count' => 1], 'messages', 'fr'),
        );
        self::assertSame(
            'Vous avez 5 articles dans votre panier.',
            $this->translator->trans('cart.items', ['count' => 5], 'messages', 'fr'),
        );
    }

    public function testCartPluralEnglish(): void
    {
        self::assertSame(
            'You have 1 item in your cart.',
            $this->translator->trans('cart.items', ['count' => 1], 'messages', 'en'),
        );
        self::assertSame(
            'You have 5 items in your cart.',
            $this->translator->trans('cart.items', ['count' => 5], 'messages', 'en'),
        );
    }

    public function testOrdinalFrench(): void
    {
        self::assertSame(
            'Vous êtes 1er du classement.',
            $this->translator->trans('rank.position', ['place' => 1], 'messages', 'fr'),
        );
        self::assertSame(
            'Vous êtes 3e du classement.',
            $this->translator->trans('rank.position', ['place' => 3], 'messages', 'fr'),
        );
    }
}

Test WebTestCase avec locale dans l'URL

php
<?php

namespace App\Tests\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class LocalizedRouteTest extends WebTestCase
{
    public function testFrenchHomepage(): void
    {
        $client = static::createClient();
        $client->request('GET', '/fr/');

        self::assertResponseIsSuccessful();
        self::assertSelectorTextContains('h1', 'Bienvenue');
    }

    public function testEnglishHomepage(): void
    {
        $client = static::createClient();
        $client->request('GET', '/en/');

        self::assertSelectorTextContains('h1', 'Welcome');
    }

    public function testFallbackOnUnknownLocale(): void
    {
        $client = static::createClient();
        $client->request('GET', '/de/'); // Pas activée

        // Selon votre config : 404 ou redirect vers default
        self::assertResponseStatusCodeSame(404);
    }
}

Snapshot en CI : aucune clé manquante

bash
#!/usr/bin/env bash
# scripts/check-translations.sh
set -e

php bin/console lint:xliff translations/
php bin/console lint:yaml translations/

for locale in fr en es; do
    missing=$(php bin/console debug:translation "$locale" --only-missing 2>&1 | grep -c "missing" || true)
    if [ "$missing" -gt "0" ]; then
        echo "❌ $locale a des clés manquantes"
        php bin/console debug:translation "$locale" --only-missing
        exit 1
    fi
done

echo "OK — toutes les locales sont complètes."

🎬 Cas d'usage concrets

Scénario 1 — SaaS RH FR/EN/ES (Lucca, Eurécia, BambooHR-like)

Un SaaS RH français vend en France, Espagne, et UK. L'application supporte FR (default), EN, ES, avec PT et IT en roadmap. Les traductions sont organisées par domaine : messages.fr.yaml (UI générique), validators.fr.yaml (messages d'erreur form), security.fr.yaml (login, MFA), emails.fr.yaml (transactionnels), legal.fr.yaml (RGPD, CGU, mentions). Le locale est résolu via un LocaleListener qui prend ?lang=es → cookie → Accept-Language → fallback FR. Les pluriels ICU sont utilisés partout (voir le pattern ci-dessous). Le format des dates est délégué à l'IntlDateFormatter (formatLocalizedDate Twig) qui produit "15 janvier 2026" en FR vs "January 15, 2026" en EN. Crowdin est branché : bin/console translation:push crowdin envoie les nouvelles clés, l'équipe traduction valide, translation:pull récupère. Un test PHPUnit guard vérifie que tous les messages.fr.yaml ont un équivalent EN/ES. Les emails transactionnels sont rendus dans la locale du destinataire (stockée sur User::preferredLocale).

yaml
# Pluriel ICU type utilisé dans ce SaaS
employees.count: "{count, plural, =0 {Aucun salarié} =1 {1 salarié} other {# salariés}}"

Scénario 2 — E-commerce mode multi-pays (Sézane, Asphalte, Petit Bateau)

Une marque DTC vend en FR, EN, DE, IT, ES, NL avec spécificités par pays (TVA, devise, frais de port). L'URL est structurée /fr/produit/..., /en/product/... via les localized routes Symfony. Les contenus produits (titres, descriptions) sont traduits en base de données via KnpDoctrineBehaviors\Translatable ou gedmo/translatable — les traductions UI génériques ("Ajouter au panier", "Livraison gratuite") sont en YAML. Le Intl\Currencies formate les prix : 49,90 € en FR, €49.90 en NL, £42.50 en EN-GB (avec conversion). La pluralisation gère les particularités slaves si extension future à PL/RU. Les emails transactionnels ont 6 versions de chaque template. Les meta SEO (<title>, <meta description>) sont traduits et exposés via hreflang Twig. Le checkout adapte les libellés légaux selon le pays (mentions DGCCRF FR vs Consumer Rights Act UK). Un middleware redirige /produit/ (FR par défaut) vers /fr/produit/. Performance : translator.cache_dir + warmup, ~12k clés UI cachées en OPcache (zéro coût runtime).

Scénario 3 — Banque retail bilingue (Crédit Mutuel CIC, Société Générale)

Une banque retail française avec présence Luxembourg/Belgique propose une interface bilingue FR/EN (FR default). Les particularités : terminologie bancaire stricte (différence "compte courant" FR vs "current account" EN, "RIB" FR vs "account details" EN — pas de traduction littérale), conformité ACPR oblige une cohérence absolue. Les messages.fr.yaml/messages.en.yaml sont validés par un compliance officer avant chaque déploiement (workflow PR avec label translation-review). Les notifications SMS/email respectent la locale du compte (Account::contractualLanguage — choix client à l'ouverture, contractuel donc peu modifiable). Le formatage IBAN est uniforme international, pas de localisation. Les nombres sont formatés via format_number Twig avec locale (1 234,56 € en FR vs 1,234.56 € en EN). Les exceptions et messages d'erreur ne fuient jamais en EN si l'utilisateur est FR (et inversement) — un Symfony\Component\Translation\TranslatableMessage enveloppe systématiquement les erreurs. Audit log des changements de locale par utilisateur (rare, mais tracé pour analyse de support).

🛠️ Exemple end-to-end

Cas : SaaS RH avec résolution de locale + pluriels ICU + emails localisés + tests de couverture des clés.

php
<?php
// src/EventListener/LocaleListener.php
declare(strict_types=1);

namespace App\EventListener;

use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;

#[AsEventListener(event: 'kernel.request', priority: 20)]
final readonly class LocaleListener
{
    public function __construct(
        private Security $security,
        private array $supportedLocales = ['fr', 'en', 'es'],
        private string $defaultLocale = 'fr',
    ) {}

    public function __invoke(RequestEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return;
        }
        $request = $event->getRequest();

        $candidate = $request->query->get('lang')
            ?? $request->cookies->get('preferred_locale')
            ?? $this->fromUser()
            ?? $request->getPreferredLanguage($this->supportedLocales)
            ?? $this->defaultLocale;

        if (!in_array($candidate, $this->supportedLocales, true)) {
            $candidate = $this->defaultLocale;
        }
        $request->setLocale($candidate);
    }

    private function fromUser(): ?string
    {
        $user = $this->security->getUser();
        return $user instanceof \App\Entity\User ? $user->preferredLocale : null;
    }
}
yaml
# translations/messages.fr.yaml
welcome.greeting: "Bonjour {name}, bienvenue sur PeopleApp"
employees.count: "{count, plural, =0 {Aucun salarié} =1 {1 salarié} other {# salariés}}"
leaves.remaining: "Il vous reste {days, plural, =0 {aucun jour} =1 {1 jour} other {# jours}} de congés"
payslip.title: "Bulletin de paie de {period, date, ::MMMM yyyy}"
yaml
# translations/messages.en.yaml
welcome.greeting: "Hello {name}, welcome to PeopleApp"
employees.count: "{count, plural, =0 {No employees} =1 {1 employee} other {# employees}}"
leaves.remaining: "You have {days, plural, =0 {no days} =1 {1 day} other {# days}} of vacation left"
payslip.title: "Payslip for {period, date, ::MMMM yyyy}"
yaml
# translations/messages.es.yaml
welcome.greeting: "Hola {name}, bienvenido a PeopleApp"
employees.count: "{count, plural, =0 {Ningún empleado} =1 {1 empleado} other {# empleados}}"
leaves.remaining: "Te quedan {days, plural, =0 {ningún día} =1 {1 día} other {# días}} de vacaciones"
payslip.title: "Nómina de {period, date, ::MMMM yyyy}"
php
<?php
// src/Email/PayslipReadyEmail.php
declare(strict_types=1);

namespace App\Email;

use App\Entity\Employee;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mime\Address;
use Symfony\Component\Translation\LocaleSwitcher;

final readonly class PayslipReadyEmailBuilder
{
    public function __construct(private LocaleSwitcher $localeSwitcher) {}

    public function buildFor(Employee $employee, \DateTimeImmutable $period): TemplatedEmail
    {
        return $this->localeSwitcher->runWithLocale(
            $employee->preferredLocale,
            fn () => (new TemplatedEmail())
                ->to(new Address($employee->email, $employee->fullName))
                ->subject('email.payslip.subject') // sera traduit via Twig trans
                ->htmlTemplate('email/payslip-ready.html.twig')
                ->context([
                    'employee' => $employee,
                    'period' => $period,
                ]),
        );
    }
}
php
<?php
// tests/Translation/TranslationCoverageTest.php
declare(strict_types=1);

namespace App\Tests\Translation;

use PHPUnit\Framework\TestCase;
use Symfony\Component\Yaml\Yaml;

final class TranslationCoverageTest extends TestCase
{
    public function testAllLocalesHaveSameKeysAsFr(): void
    {
        $fr = $this->flatten(Yaml::parseFile(__DIR__ . '/../../translations/messages.fr.yaml'));

        foreach (['en', 'es'] as $locale) {
            $other = $this->flatten(Yaml::parseFile(__DIR__ . '/../../translations/messages.' . $locale . '.yaml'));
            $missing = array_diff_key($fr, $other);
            self::assertEmpty(
                $missing,
                sprintf('Locale %s missing keys: %s', $locale, implode(', ', array_keys($missing))),
            );
        }
    }

    private function flatten(array $array, string $prefix = ''): array
    {
        $result = [];
        foreach ($array as $key => $value) {
            $newKey = $prefix === '' ? $key : $prefix . '.' . $key;
            if (is_array($value)) {
                $result += $this->flatten($value, $newKey);
            } else {
                $result[$newKey] = $value;
            }
        }
        return $result;
    }
}
bash
# Synchronisation Crowdin / Lokalise
bin/console translation:push crowdin --force --delete-obsolete
bin/console translation:pull crowdin --force --domains=messages,validators,emails
bin/console debug:translation fr --only-unused
bin/console debug:translation en --only-missing

Couverture : listener locale + ICU plurals + email localisé via LocaleSwitcher + test de couverture + pipeline Crowdin. Le SaaS livre une nouvelle locale (PT-PT) en moins d'une semaine.


🏛️ Comment un staff engineer raisonne sur l'i18n

Le pipeline interne du Translator (ce qui se passe vraiment)

Comprendre la chaîne évite 90 % des bugs « ma traduction n'apparaît pas ». Pour un trans('cart.items', ['count' => 3]) :

trans(id, params, domain?, locale?)

   ▼  1. Résout la locale (param explicite > locale courante > defaultLocale)
   ▼  2. Charge le MessageCatalogue de cette locale (depuis le cache compilé si dispo)
   ▼  3. Cherche `id` dans le domain → MISS → descend la chaîne de fallbacks
   │        fr_BE → fr → en (selon `fallbacks`), s'arrête au 1er hit
   ▼  4. MISS total → retourne `id` BRUT (pas d'exception en prod !)
   ▼  5. HIT → choisit le formatter :
   │        domain finit par `+intl-icu` → IntlFormatter (ext-intl, MessageFormatter natif)
   │        sinon                        → MessageFormatter legacy (`%var%`, `|` pluriels)
   ▼  6. Interpole params + applique les règles CLDR (plural/select/ordinal)
   ▼  7. Retourne la string finale

Deux décisions d'architecture en découlent :

  • Le MISS est silencieux par design. En prod, une clé absente retourne son id (cart.items s'affiche tel quel à l'écran). C'est un choix UX (ne jamais crasher une page pour un texte manquant) qui déplace la détection vers la CI. Si vous voulez un fail-fast, c'est en test/CI (debug:translation --only-missing), pas au runtime.
  • Le choix du formatter est lié au nom de domain, pas à un flag. Le suffixe +intl-icu n'est pas cosmétique : il route vers une implémentation C totalement différente. Mélanger les deux dans la même app crée des phrases où {name} fonctionne ici et %name% là — d'où le pitfall n°4.

Catalogue : coût de compilation et cache

PhaseCe qui se passeCoût
devCatalogue rechargé à chaque requête si un fichier de traduction a changé (file watcher)élevé mais invisible
prod (warmup)cache:clear/cache:warmup compile chaque (locale × domains) en PHP arrays sérialisés dans var/cache/prod/translations/une fois par déploiement
prod (runtime)require du fichier compilé → array en mémoire, mis en OPcachequasi nul (lecture mémoire)

Implication staff : une nouvelle locale ne coûte rien au runtime (juste un fichier compilé de plus), mais un translation:pull à chaud ne sera pas pris en compte tant que le cache prod n'est pas reconstruit (pitfall n°6). Le MetadataAwareInterface et les fallbacks sont gelés dans le cache compilé — changer fallbacks en config exige un cache:clear.

Tradeoffs de format : XLIFF vs YAML vs PHP vs CSV

FormatPourContreQuand le choisir
XLIFF 2.0Standard industrie traduction (TMS comme Crowdin/Lokalise le parlent nativement), supporte états (needs-translation, translated, final), métadonnées, notes traducteurverbeux, illisible en review Git brutdès qu'un outil de traduction externe ou des traducteurs non-devs entrent en jeu
YAMLlisible, diff Git propre, rapide à écrirepas d'états ni de notes, pièges de quoting (yes/no/: → cast), nesting ambigupetites apps, source fr éditée par des devs
PHPle plus rapide à charger (pas de parse), peut contenir de la logiquepas exportable vers un TMS, non éditable par un traducteurgénération automatique, cas de perf extrême
CSV / .potooling externe (gettext, tableurs)pauvre en structuremigration depuis legacy gettext

Règle pragmatique : source en YAML pour les devs, ou XLIFF dès que des traducteurs/TMS interviennent. Ne mélangez pas les deux pour une même (domain, locale) — l'ordre de chargement des loaders devient une source de surprise.

Observabilité : savoir quelles clés manquent en prod

Le trans silencieux est un piège opérationnel : vous découvrez les clés manquantes via des tickets support, pas des alertes. Branchez le LoggingTranslator (déjà actif en dev, à activer en prod avec un canal dédié) et faites remonter les translation.WARNING: Translation not found :

yaml
# config/packages/prod/translation.yaml
framework:
    translator:
        logging: true   # logge les MISS et les fallbacks utilisés
yaml
# config/packages/prod/monolog.yaml — canal dédié + sampling pour ne pas noyer les logs
monolog:
    channels: ['translation']
    handlers:
        translation_misses:
            type: fingers_crossed
            action_level: warning
            channels: ['translation']
            handler: sentry   # ou un index Loki/ES requêtable

Un dashboard « top 20 clés manquantes par locale sur 7 jours » transforme l'i18n d'un travail de devinette en backlog priorisé. Côté business : un MISS sur checkout.cta en de est un bug de conversion, pas une coquille.

Sécurité — la surface d'attaque réelle de l'i18n

  1. XSS via catalogue + |raw : si une clé contient du HTML interpolé avec une variable utilisateur non échappée, c'est un XSS stocké. Règle : jamais de balises dans le catalogue couplées à une variable user en |raw.
  2. ICU injection / DoS de format : ne construisez jamais une string ICU à partir d'input utilisateur (trans($userInput, ...)). Un pattern ICU malformé lève une exception runtime (DoS) ou, pire, fait fuiter de la structure. Les id doivent être des constantes du code.
  3. Locale non validée → path/header poisoning : si vous résolvez la locale depuis ?lang= ou un cookie sans whitelist, un attaquant peut tenter ?lang=../../etc (loader path) ou polluer le cache de page (Vary: Accept-Language). Validez toujours contre enabled_locales (voir le LocaleListener end-to-end qui fait in_array(..., true)).
  4. Cache poisoning HTTP : set_content_language_from_locale ajoute Content-Language; assurez-vous que votre CDN inclut la locale (ou Vary: Accept-Language/cookie) dans la clé de cache, sinon un user fr peut servir une page en cachée à un user de.

Failure modes à connaître par cœur

SymptômeCause racine probableFix
La clé s'affiche brute (cart.items)MISS total après fallbacks ; ou mauvais domaindebug:translation <locale>; vérifier domain & suffixe +intl-icu
{count} apparaît littéralementdomain sans +intl-icu mais syntaxe ICU dans le messagerenommer le fichier en …+intl-icu.<locale>.xliff
%name% apparaît littéralementl'inverse : ICU attend {name}, pas %name%uniformiser sur {name}
Bon texte, mauvais pluriel à 1.5/0règle CLDR de la locale cible ≠ source copiée-colléevérifier les catégories CLDR (FR one = 0,1 ; EN one = 1 strict)
Traduction OK en dev, brute en prodcache prod pas reconstruit après pull/déploiementcache:clear / warmup dans le pipeline de déploiement
Email envoyé dans la mauvaise languelocale de la requête HTTP utilisée au lieu de celle du destinataireLocaleSwitcher::runWithLocale($user->locale, ...)
Date January dans une UI FRconcaténation manuelle au lieu d'IntlDateFormatter/{d, date, ::...}formater via ICU skeleton ou format_datetime Twig

🔁 Quand utiliser / éviter

Utilisez symfony/translation quand :

  • Votre app cible plusieurs marchés (FR/EN/ES…).
  • Vous avez besoin de pluriels corrects (pas que pour l'i18n — même un site mono-langue gagne en clarté avec ICU plurals).
  • Vous voulez séparer texte UI et code (i18n même mono-langue facilite la maintenance).
  • Vous synchronisez avec un service de traduction (Crowdin, Lokalise).

Évitez (ou complétez) si :

  • Très peu de texte UI (app interne, 5 boutons) : i18n est overkill, gardez en dur.
  • Contenu utilisateur traduit (articles, descriptions de produits) : ce n'est pas du i18n d'UI. Utilisez une lib type knplabs/doctrine-behaviors ou gedmo/translatable pour traduire des données.
  • Site statique : un générateur (Hugo, Astro) gère mieux le multi-locale sans Symfony.
  • Live translation editor : Symfony ne fournit pas d'UI in-app pour traduire. Utilisez Crowdin in-context ou un éditeur dédié.

🏋️ Exercices

Progression : implémenter → durcir → casser-puis-réparer. Chaque exercice suppose un projet Symfony 7.x avec symfony/translation et ext-intl.

1. ICU complet : pluriel + select + ordinal dans une même clé

Objectif — écrire une clé notifications.summary qui dit, en FR et EN, « {gender} a aimé votre {count}{ordinal} publication / vos {count} publications » avec accord correct.

Exemple attendu : notifications.summary avec gender=female, count=1 → « Elle a aimé votre 1re publication. » ; count=3 → « Elle a aimé vos 3 publications. »

Indice/Solution — imbriquez select (gender) > plural (count), et pour le cas one insérez un selectordinal sur le même count. Testez les bornes CLDR FR : count=1one + ordinal 1re, count=0=0 ou one selon votre choix produit. Couvrez chaque branche par un assertSame (6 cas minimum : 2 genres × {0,1,plusieurs}).

2. LocaleSwitcher pour des emails et un export PDF par lot

Objectif — générer 1 000 bulletins de paie, chacun dans la locale du salarié, depuis un Messenger handler, sans fuite de locale entre deux itérations.

Indice/Solution — injectez LocaleSwitcher et enveloppez chaque rendu dans runWithLocale($employee->preferredLocale, fn () => ...). Vérifiez qu'à la sortie du callback la locale courante est restaurée (le switcher la remet d'office). Piège : ne pas faire setLocale() global dans une boucle (état partagé → le bulletin n°500 peut hériter de la locale n°499 si une exception court-circuite la restauration). Écrivez un test qui alterne fr/en/es et assert la locale courante inchangée après la boucle.

3. Guard CI : couverture, clés inutilisées, ICU valide

Objectif — un script qui fait échouer la CI si (a) une locale a une clé absente vs la source fr, (b) une clé du catalogue n'est référencée nulle part dans le code, (c) un message ICU est syntaxiquement invalide.

Indice/Solution — combinez lint:xliff + lint:yaml (syntaxe), translation:lint (ICU, 7.2+) ou un test qui instancie MessageFormatter sur chaque pattern, debug:translation <locale> --only-missing (couverture) et --only-unused (clés mortes). Faites de la parité un test PHPUnit déterministe (cf. TranslationCoverageTest) plutôt qu'un grep fragile — le test doit lister quelles clés manquent, pas juste échouer.

4. Break-then-fix : la traduction disparaît en prod après un pull

Objectif — reproduire puis corriger « les nouvelles traductions Crowdin n'apparaissent pas en prod, mais OK en local ».

Indice/Solution — reproduisez : translation:pull crowdin met à jour les fichiers, mais var/cache/prod/translations/ contient encore le catalogue compilé de l'ancien déploiement → MISS/anciennes valeurs. Le bug est dans le pipeline, pas le code. Fix : ajoutez cache:warmup (ou cache:clear) après le pull dans l'étape de déploiement, et faites du pull une étape build-time (immuable) plutôt que runtime. Bonus : montrez pourquoi un pull runtime sur plusieurs pods crée une fenêtre d'incohérence (certains pods chauds, d'autres froids).

5. Break-then-fix : pluriel faux en arabe / russe

Objectif — une app étendue à ar affiche « 2 ملفات » correctement mais casse à 3, 11, 100. Diagnostiquer et corriger.

Indice/Solution — l'arabe a 6 catégories CLDR (zero, one, two, few, many, other) ; un catalogue copié de l'anglais (2 cas) ne couvre que one/other, donc 3 (few), 11 (many), 100 (other mais règle ≠ EN) tombent sur la mauvaise branche. Fix : écrire les 6 branches en suivant les CLDR plural rules ar. Test paramétré sur {1,2,3,11,100, 1000000}. Leçon transférable : ne jamais inférer les catégories plurielles d'une locale depuis une autre — toujours consulter CLDR.

6. (Stretch) Cohérence ICU PHP ↔ JS

Objectif — exposer le catalogue messages au frontend et prouver par un test que intl-messageformat (JS) produit exactement la même sortie que trans() (PHP) pour pluriels et ordinaux.

Indice/Solution — exportez le catalogue ICU brut en JSON (commande app:translations:export-json, mais sans pré-formater : exportez les patterns, pas les résultats). Côté JS, instanciez new IntlMessageFormat(pattern, locale).format(params). Écrivez un test cross-runtime (un fixture de couples (clé, params, attendu) partagé) lancé en PHPUnit et en node:test. Le piège : si vous exportez le résultat déjà formaté côté PHP, le JS ne peut plus repluraliser dynamiquement — exportez le pattern.

🎤 En entretien

Q : Pourquoi transChoice() a-t-il été supprimé, et qu'est-ce qui le remplace ? R : transChoice() encodait les pluriels avec une syntaxe pipe propriétaire ({0} aucun|{1} un|]1,Inf[ plusieurs) qui ne couvrait pas les vraies règles CLDR (arabe à 6 cas, ordinaux, genre). ICU MessageFormat ({count, plural, ...}) délègue à la lib Unicode (ext-intl) et gère toutes les locales correctement, donc trans() + ICU le remplace intégralement. transChoice() a été déprécié en 4.2 puis supprimé en 5.0 — sur du Symfony 6.4/7.x il n'existe plus du tout.

Q : Un user voit cart.items brut à l'écran en prod. Quelle est votre démarche ? R : C'est un MISS total (le translator retourne l'id quand aucune traduction n'est trouvée après fallbacks). Je vérifie dans l'ordre : le bon domain est-il passé, le suffixe +intl-icu correspond-il à la syntaxe du message, la clé existe-t-elle dans le catalogue de la locale (ou un de ses fallbacks), et surtout le cache prod a-t-il été reconstruit après le dernier pull/déploiement. bin/console debug:translation <locale> tranche en une commande.

Q : Comment garantissez-vous qu'un email part dans la langue du destinataire et non celle de la requête HTTP ? R : La locale courante est celle de la requête de l'expéditeur (ou d'un cron sans locale). J'enveloppe le rendu dans LocaleSwitcher::runWithLocale($recipient->preferredLocale, fn () => ...), qui bascule puis restaure la locale — y compris si une exception survient. La locale du destinataire doit être une donnée persistée (User::preferredLocale), pas inférée du contexte d'envoi.

Q : Quels sont les coûts de performance et de sécurité d'ajouter une nouvelle locale ? R : Perf : quasi nul au runtime — chaque (locale × domain) est compilé en array PHP sérialisé au warmup et servi depuis OPcache ; le seul coût est le warmup au déploiement et l'empreinte mémoire du catalogue. Sécurité : la locale doit être whitelistée (enabled_locales) avant tout usage comme path/loader/header, sinon on ouvre une porte à du cache poisoning HTTP (Vary mal configuré) ou du path traversal sur le loader. Le vrai coût est process : traduction, review (compliance/legal selon le domaine), et tenue de la parité des clés en CI.

🔗 Liens

Bibliothèque tech perso — Achref