symfony/translation
Symfony's Translation component gives you the tools to internationalize your application: translate messages using locale-aware catalogs, handle pluralization and ICU message formats, and load translations from PHP, XLIFF, YAML, JSON, or PO files. Use it standalone or with the full Symfony framework...
Install (standalone / non-Laravel code):
composer require symfony/translation
In a Laravel app you already have this transitively — illuminate/translation extends Symfony\Component\Translation\Loader\FileLoader and uses Symfony's MessageSelector for trans_choice(). So the component is present, but the Symfony Translator class itself is not wired into the container. Don't assume app(Symfony\Contracts\Translation\TranslatorInterface::class) resolves; bind your own instance explicitly.
Smallest working setup:
use Symfony\Component\Translation\Translator;
use Symfony\Component\Translation\Loader\ArrayLoader;
$translator = new Translator('fr_FR');
$translator->addLoader('array', new ArrayLoader());
$translator->addResource('array', [
'Hello World!' => 'Bonjour !',
], 'fr_FR');
echo $translator->trans('Hello World!'); // Bonjour !
Where to look first (in this order):
Translator::trans() and Translator::transChoice()/%count% handling — the whole API contract lives there.MessageCatalogue — the in-memory map of domain => id => message, plus metadata and fallback merging. Everything you debug ends up here.Loader/* — ArrayLoader, PhpFileLoader, XliffFileLoader, CsvFileLoader, JsonFileLoader, YamlFileLoader. Pick one and standardize.Formatter/* — MessageFormatter (default, %param%) vs IntlFormatter (ICU {param} + {count, plural, ...}).Provider/* — CrowdinProvider, LokaliseProvider, NullProvider for push/pull sync (commands live in FrameworkBundle).Extractor/* — PhpAstExtractor/PhpExtractor + ChainExtractor if you want to mine untranslated strings out of your own codebase.First real use case: you have a CLI/queue worker or an API endpoint that renders user-facing strings and you can't rely on the framework's __() helper (no request locale, no container, or you want XLIFF catalogs shared with a translation vendor). Instantiate the Translator, register one loader, addResource() your files, call trans().
Bind one translator as a singleton, and let everything else be injection.
// AppServiceProvider::register()
$this->app->singleton(Translator::class, function ($app) {
$t = new Translator($app->getLocale());
$t->addLoader('php', new PhpFileLoader());
$t->addLoader('xliff', new XliffFileLoader());
$t->setFallbackLocales(['en']);
foreach (glob(lang_path('symfony').'/*.{php,xliff}', GLOB_BRACE) as $file) {
// messages.en.php / messages+intl-icu.en.xliff
[$domain, $locale] = parse_catalog_filename($file);
$t->addResource(pathinfo($file, PATHINFO_EXTENSION), $file, $locale, $domain);
}
$t->setConfigCacheFactory(new ConfigCacheFactory(app()->environment('production')));
return $t;
});
Caveat: addResource() at boot is fine for tens of files; for hundreds, register a custom loader instead (below) so lookups stay lazy.
One loader per source of truth. This is the pattern that actually pays off day-to-day:
| Source | Loader | Why |
|---|---|---|
| Unit tests | ArrayLoader |
no filesystem, no cache, deterministic |
| App strings | PhpFileLoader |
opcache-friendly, no parse cost |
| Vendor / shared catalogs | XliffFileLoader |
round-trips with translators' tools |
| Hand-off to translators | CsvFileLoader / JsonFileLoader |
non-devs can edit in a spreadsheet |
| DB-backed / dynamic content | custom LoaderInterface |
per-tenant, per-user-generated translations |
Custom loader for DB or remote catalogs. This is the extension point people miss. Implement LoaderInterface::load($resource, $locale, $domain) returning a MessageCatalogue, then addLoader('db', $loader) and addResource('db', 'tenant_42', $locale, 'messages'). The $resource argument is yours to define — a tenant id, an S3 key, a locale row id. Wrap the loader with an in-memory memo + PSR-6 cache to avoid one query per trans() call.
Domains, not dot-notation keys. This trips up every Laravel dev:
// Laravel: first segment is the file
__('messages.welcome');
// Symfony component: domain is an explicit argument, key is flat
$translator->trans('welcome', [], 'messages');
$translator->trans('invoice.paid', ['%id%' => 42], 'billing', 'de_DE');
Use domains to separate validation, emails, legal — it lets a vendor translate one domain without touching the rest, and it lets you swap loaders per domain (e.g. legal from a signed-off XLIFF, notifications from the DB).
Pluralization and formatting. Symfony's MessageSelector handles interval syntax:
$translator->trans('apples', ['%count%' => 5]);
// 'apples' => '{0} No apples|{1} One apple|]1,Inf[ %count% apples'
You must pass %count% for the selector to pick a branch — passing ['count' => 5] silently returns the raw string. In Laravel's trans_choice() the placeholder is :count, not %count%, because Laravel does its own replacement after the selector runs. For currency/number/date formatting inside messages, switch the formatter to IntlFormatter and use ICU syntax ({count, plural, one{# apple} other{# apples}}); in Symfony this is selected by the +intl-icu filename suffix (messages+intl-icu.en.yaml). Laravel has no equivalent suffix convention — if you need ICU, register the IntlFormatter on your own translator instance.
Translatable objects instead of raw keys. Implement TranslatableInterface (or use TranslatableMessage) on DTOs/enums so domain code carries its own message id + params:
public function trans(TranslatorInterface $t, ?string $locale = null): string
{
return $t->trans('order.status.'.$this->value, [], 'orders', $locale);
}
Then $translator->trans($order->status) works, and templates don't hardcode strings. Symfony 7.4 added StaticMessage for the same idea with a literal string; Symfony 8 removed TranslatableMessage::__toString() — call trans() or getMessage() explicitly or your upgrade will fatal.
Extraction as a CI job, not a manual chore. Wire PhpAstExtractor + ChainExtractor into an Artisan command that scans app/ and resources/views/ for trans()/__() calls, diffs against your source catalogue, and fails the build if new keys appear without translations. This is the single highest-value automation — it's what turns your translation turnaround from weeks into days.
Push/pull to Crowdin/Lokalise via Symfony\Component\Translation\Provider\* when you're past ~50 locales. Note the changelog history here: pushing only the default locale without --force used to replace remote files with partial data (#63231), and very large Lokalise projects hit "Project too big for sync export" (#62348). Pin a version that includes those fixes before going live.
Testing pattern: in unit tests, build a Translator with ArrayLoader and assert on the id, not the rendered string. For integration tests, assert the catalogue has the key in every locale ($catalogue->has($id, $domain)) so missing translations fail CI rather than shipping the raw key.
trans('foo.bar') returns "foo.bar" rather than throwing. Don't rely on exceptions. Add a decorator around TranslatorInterface that logs $translator->getCatalogue($locale)->defines($id, $domain) === false, or run a CI assertion.has() vs fallback. $translator->getCatalogue('de')->has($id) can be false while trans($id, [], null, 'de') still returns a German-ish string — because the catalogue merges fallbacks when loaded. To check native coverage, use $catalogue->defines($id, $domain) on a catalogue loaded without fallback merge, or getCatalogue()->all($domain).trans() does no HTML escaping. Use {{ }} in Blade, htmlspecialchars() elsewhere. If a translator writes <script> into a message, {!! !!} will ship it. Treat catalogs as trusted-but-reviewable input.% in message parameters. A literal % in a message is interpreted as a placeholder delimiter; double it (%%) or your message will silently lose text. Same trap with %count% typos.domain.locale.format (e.g. messages.en.yaml, validators.de.xlf). A file named en.messages.php won't be found and you'll debug the loader for an hour. XliffFileLoader keys by the resname attribute, falling back to <source> — if your vendor exports <trans-unit id="..."> without resname, your keys will be the source strings and every lookup misses.ConfigCache/Translator caches compiled catalogues; Laravel's php artisan cache:clear does not touch them, and config:cache doesn't either. Stale translations after a deploy are almost always a cache-dir permissions or warmup issue, not a loader bug.getCatalogue() over the whole tree in loops.CsvFileLoader choked on empty lines historically (fixed in 6.4.32 / 7.4.4 / 8.0.4) and is sensitive to BOM and quoting. If you accept translator-edited CSVs, pin a patched version and validate on import.fr_FR, fr-FR, and fr are three distinct keys to the Translator. Normalize at the boundary (Locale::canonicalize()) or you'll get silent fallbacks.TranslatableMessage::__toString() is deprecated there and removed in 8.0 — migrate to trans()/getMessage() before jumping.Provider/, Extractor/, Dumper/, Formatter/, and Catalogue/ — the push/pull, extraction, and merge operations you're about to hand-roll are already in the package.How can I help you explore Laravel packages today?