Symfony Translation — i18n industriel
TL;DR —
symfony/translationest 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:xliffgarantissent qu'aucune clé n'est manquante. Le frontend JS récupère les mêmes traductions via JSON exporté oubazinga-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. Utiliseradmin,forms,emailspour modulariser. - Loader : qui sait lire un fichier (
XliffFileLoader,YamlFileLoader,PhpFileLoader,CsvFileLoader).
🛠️ Code minimal (PHP 8.2+)
Installation et config de base
composer require symfony/translation
# Pour ICU MessageFormat avancé (recommandé pour pluriels propres)
# php-intl doit être installé côté système# 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 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 :
<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
countest passé comme paramètre numérique, et c'est le format qui choisit le cas. Pas besoin d'appeler une méthode spéciale commetransChoice()(supprimée en Symfony 5.0).
Usage en 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
{# 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
# config/packages/framework.yaml
framework:
default_locale: 'fr'
set_locale_from_accept_language: true # depuis Symfony 6.4
set_content_language_from_locale: trueLe LocaleListener lit automatiquement :
- Préfixe de route
_locale(/fr/...,/en/...) - Attribut de requête
_locale - Header
Accept-Language(si activé) defaultLocaleen fallback
Routes localisées
#[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
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
# 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:extractpuisgit diff --exit-code→ bloque si des clés manquent. - CI :
lint:xliff translations/→ vérifie la syntaxe XLIFF.
# Validation XLIFF en CI
php bin/console lint:xliff translations/
php bin/console lint:yaml translations/3. Gestion des clés manquantes en CI
# 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: trueLe 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).
# config/packages/dev/translation.yaml
framework:
translator:
logging: trueEt pour bloquer la CI :
# Script CI custom
php bin/console debug:translation fr --only-missing
test $? -eq 0 || exit 14. Synchronisation frontend (JS)
Deux approches :
A. Export JSON à la build
<?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;
}
}# À la build (ou dans le post-deploy)
php bin/console app:translations:export-jsonLe 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 :
composer require willdurand/js-translation-bundle# config/routes.yaml
bazinga_js_translation:
resource: "@BazingaJsTranslationBundle/Resources/config/routing/routing.yml"<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
# config/packages/translation.yaml
framework:
translator:
providers:
crowdin:
dsn: '%env(CROWDIN_DSN)%'
locales: ['fr', 'en', 'es']
domains: ['messages', 'admin']# 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=messagesLe 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
| Version | Apport 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.0 | Nettoyage 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.0 | API 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
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).Pluriels français vs anglais. EN a 2 cas (
one,other), FR a aussione+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.5en FR →one(oui, vraiment), en EN →other.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.%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}.Locale
fr_FRvsfr. Le composant fallback :fr_FR→fr→en(selonfallbacks). Mais si vous avez les deux fichiers, le plus spécifique gagne. Cohérence : choisissez une convention dans tout le projet.Caches qui ne se rafraîchissent pas en prod. Après un
pullde provider, vidervar/cache/prod/translations/est obligatoire (ou redéployer).HTML dans les messages. Marqueur direct = injection si var non échappée. Twig échappe par défaut, mais soyez vigilants avec
rawfilter. Mieux : structurer le HTML hors du message — un filtretransretourne 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 à
|rawsur une variable utilisateur.Validator domain. Les messages d'erreurs
@Assert\NotBlank(message: '...')cherchent dans le domainvalidators(pasmessages). Idemsecurity.JS désynchronisé. Si votre app PHP a
cart.itemsen+intl-icumais que le JSON exporté ne respecte pas l'ICU, le frontend cassera. Soit tout le monde parle ICU, soit personne.Tests unitaires sans
framework.translator.fallbacks. Un test qui trans une clé absente dansfrmais présente enenpeut passer en local et casser en CI si la config diffère.
🧪 Testing — phpunit + KernelTestCase
Test de traduction unitaire
<?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
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
#!/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).
# 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
// 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;
}
}# 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}"# 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}"# 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
// 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
// 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;
}
}# 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-missingCouverture : 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 finaleDeux décisions d'architecture en découlent :
- Le MISS est silencieux par design. En prod, une clé absente retourne son
id(cart.itemss'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-icun'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
| Phase | Ce qui se passe | Coût |
|---|---|---|
| dev | Catalogue 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 OPcache | quasi 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
| Format | Pour | Contre | Quand le choisir |
|---|---|---|---|
| XLIFF 2.0 | Standard industrie traduction (TMS comme Crowdin/Lokalise le parlent nativement), supporte états (needs-translation, translated, final), métadonnées, notes traducteur | verbeux, illisible en review Git brut | dès qu'un outil de traduction externe ou des traducteurs non-devs entrent en jeu |
| YAML | lisible, diff Git propre, rapide à écrire | pas d'états ni de notes, pièges de quoting (yes/no/: → cast), nesting ambigu | petites apps, source fr éditée par des devs |
| PHP | le plus rapide à charger (pas de parse), peut contenir de la logique | pas exportable vers un TMS, non éditable par un traducteur | génération automatique, cas de perf extrême |
| CSV / .po | tooling externe (gettext, tableurs) | pauvre en structure | migration 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 :
# config/packages/prod/translation.yaml
framework:
translator:
logging: true # logge les MISS et les fallbacks utilisés# 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êtableUn 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
- 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. - 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. Lesiddoivent être des constantes du code. - 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 contreenabled_locales(voir leLocaleListenerend-to-end qui faitin_array(..., true)). - Cache poisoning HTTP :
set_content_language_from_localeajouteContent-Language; assurez-vous que votre CDN inclut la locale (ouVary: Accept-Language/cookie) dans la clé de cache, sinon un userfrpeut servir une pageencachée à un userde.
Failure modes à connaître par cœur
| Symptôme | Cause racine probable | Fix |
|---|---|---|
La clé s'affiche brute (cart.items) | MISS total après fallbacks ; ou mauvais domain | debug:translation <locale>; vérifier domain & suffixe +intl-icu |
{count} apparaît littéralement | domain sans +intl-icu mais syntaxe ICU dans le message | renommer le fichier en …+intl-icu.<locale>.xliff |
%name% apparaît littéralement | l'inverse : ICU attend {name}, pas %name% | uniformiser sur {name} |
Bon texte, mauvais pluriel à 1.5/0 | règle CLDR de la locale cible ≠ source copiée-collée | vérifier les catégories CLDR (FR one = 0,1 ; EN one = 1 strict) |
| Traduction OK en dev, brute en prod | cache prod pas reconstruit après pull/déploiement | cache:clear / warmup dans le pipeline de déploiement |
| Email envoyé dans la mauvaise langue | locale de la requête HTTP utilisée au lieu de celle du destinataire | LocaleSwitcher::runWithLocale($user->locale, ...) |
Date January dans une UI FR | concaté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-behaviorsougedmo/translatablepour 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=1 → one + 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
- Doc Translation : https://symfony.com/doc/current/translation.html
- ICU MessageFormat : https://unicode-org.github.io/icu/userguide/format_parse/messages/
- CLDR plural rules : https://www.unicode.org/cldr/charts/45/supplemental/language_plural_rules.html
- Providers (Crowdin/Lokalise) : https://symfony.com/doc/current/translation.html#translation-providers
intl-messageformat(JS) : https://formatjs.io/docs/intl-messageformat/- Bazinga JS Translation : https://github.com/willdurand/BazingaJsTranslationBundle