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...
Architecture fit
The package is a framework-agnostic i18n runtime: a Translator bound to a locale, a MessageCatalogue per locale/domain, pluggable loaders (XLIFF 1.2/2.0, YAML, JSON, CSV, INI, PHP array, PO, Qt), pluggable dumpers, ICU-aware formatters, and — uniquely — translation-provider adapters (Crowdin, Lokalise, Phrase) with pull/push console commands. It is MIT, ~6.6k stars, and released under the Symfony monorepo cadence with parallel 6.4 / 7.4 / 8.0 / 8.1 branches.
The central architectural fact for us: Laravel already ships a translation runtime. illuminate/translation was historically derived from this component and has long since diverged — different loader set (PHP arrays + JSON only), different placeholder semantics (:name and legacy {0}/{1} MessageSelector, not ICU {count, plural, ...}), different catalogue model, and __() / Lang::get() rather than trans(). Adding symfony/translation to a Laravel codebase therefore creates two parallel i18n stacks unless we deliberately pick one. That is the decision, not the install.
There are three genuinely different projects hiding behind "integrate symfony/translation," and they carry very different risk:
| Posture | What it buys | Risk |
|---|---|---|
A. Side-by-side — Symfony Translator as a separate container singleton for a bounded domain (transactional emails, API error catalogues, ICU plural strings) |
ICU plurals/select/gender, XLIFF ingestion, provider sync — none of which Laravel offers | Low. No contact with Illuminate\Contracts\Translation\Translator |
B. Adapter — implement Laravel's Translator contract backed by Symfony's engine, rebind in a service provider |
One __() API, better formatting underneath |
Medium. Must faithfully reproduce get(), choice(), has(), getLocale()/setLocale(), getSelector(), package::file.key namespacing, JSON-key fallback, and :placeholder replacement |
C. Replacement — swap illuminate/translation wholesale |
Conceptual purity | High and not recommended. Validation, pagination, auth, password-reset and mail notifications all resolve messages through that contract; breaking it breaks framework error output before it breaks our features |
Integration feasibility
Technically straightforward: composer require symfony/translation:^7.4 alongside Laravel, no namespace or autoload collision. The real gates are:
^7.4 (LTS, PHP ≥ 8.2) unless the whole fleet is on 8.4+, and treat ^8 as a deliberate later migration.ext-intl; the YAML loader needs symfony/yaml; the pull/push commands need symfony/console + symfony/dependency-injection. Standalone usage means we hand-wire loaders and formatters that FrameworkBundle would normally configure — that wiring is ours to own and test.lang/{locale}/{file}.php + lang/{locale}.json. Symfony wants domain.locale.format. We need an explicit mapping decision and probably a custom loader bridging to Laravel's paths, or a build step that compiles Laravel PHP arrays into Symfony catalogues.Technical risk
App::setLocale() vs Translator::setLocale() will drift unless one is derived from the other. This is the most likely silent production bug.:count strings to ICU changes output for locales with complex plural rules (pl, ru, ar). Needs a per-locale regression fixture set, not spot checks.XliffUtils::shouldEnableEntityLoader fix). Third-party translation files are untrusted input.symfony/validator, symfony/form, symfony/framework-bundle, and API Platform. Likewise the assessment-pm.md framing ("replace Lokalise/Phrase for $50K–150K/yr") overstates what a runtime component delivers: it is the delivery half of a TMS, not extraction, review workflow, MT, or QA. Do not carry those numbers into a roadmap deck unvalidated.Key questions
Stack fit
Best fit is posture A deployed as an internal Composer package: a LocalizationServiceProvider that registers a configured Translator singleton, a loader map pointing at a build-artifact directory of dumped PHP-array catalogues, and thin facade/helper for the bounded domains we choose (ICU-heavy strings, email templates, API error contracts). Laravel's __() remains the default for UI strings. This captures the differentiating capability — ICU formatting, XLIFF ingestion, provider sync, catalogue operations — without touching the contract the framework depends on.
If the org genuinely needs one API, do B as a decorator that wraps rather than replaces: implement Laravel's contract, delegate to Symfony's Translator, and keep Lang resolving through the existing class for anything the adapter doesn't explicitly claim. Feature-flag it per domain and shadow-compare outputs.
Migration path
Do not migrate strings. Migrate capability, domain by domain:
{0} selector or manual str_replace pluralization.lang/ into Symfony PHP-array catalogues and dumps them; this is pure upside (parse-once, OPcache-friendly) and requires no runtime swap.Compatibility
Pin an explicit floor, not a caret. The changelog shows patch releases routinely carrying fixes in neighbouring components (DependencyInjection, Console) and translation-specific correctness fixes we depend on: Crowdin push truncating catalogues without --force (#63231), Lokalise "project too big for sync export" (#62348), CSV empty-line handling (#63064), TranslatableInterface support in IdentityTranslator (#61111). Set the floor at or above the patches containing these, and commit a lockfile entry.
Avoid mixing major branches in one monorepo if any package resolves a different Symfony major transitively. Run composer why symfony/translation before and after.
Sequencing
Catalogue format decision → build-artifact dumper + CI parity test → pilot domain (side-by-side, diffed) → provider sync → adapter evaluation → deprecation plan for any bespoke JSON translation hacks. Each step is independently valuable and independently revertible; none requires the next.
Maintenance
Cadence is driven by the Symfony monorepo, not this split repo: monthly-ish patch releases plus minors, with four maintained branches. Budget a recurring ~1 engineer-day/quarter for floor bumps and changelog triage, concentrated on translation-specific fixes. The split repo is a read-only mirror — issues and PRs live in symfony/symfony, so upstream contribution is a two-hop process with a higher bar than a typical package. Version policy must be centralised (one floor in a shared CI config), or services will drift across 6.4/7.4/8.x.
Support
Effectively best-in-class for PHP: large contributor base, LTS branches with long security windows (6.4 and 7.4 both carry multi-year security support), documented upgrade paths. The corollary is that support is community-shaped, not vendor SLA — no one to escalate to. If localization is business-critical for a regulated launch, we own the on-call.
Scaling
Catalogues are hydrated into memory per locale/domain. For a large matrix (say 50 locales × 100k strings), per-request file parsing is the dominant cost. Mitigations, in order of impact: (1) dump catalogues to PHP arrays at build time and warm OPcache — this alone removes all runtime parsing; (2) lazy-load domains rather than all catalogues on boot; (3) cache the resolved catalogue in Redis per (locale, domain, catalogue-version) for multi-node fleets; (4) keep locale fallback chains shallow — each fallback level is a lookup, and deep chains hide missing translations.
Failure modes
trans() returns the message ID when a key is absent. In production this looks like English leaking into a Japanese UI, not an error. Mitigate with a CI gate that fails on missing keys per locale, and a non-production strict mode.App::getLocale() and Translator::getLocale() — wrong-language output, no exception.ext-intl silently degrades ICU formatting: plural strings render with the wrong branch or raw braces. Assert the extension at boot.--force can replace a remote catalogue with partial data (fixed upstream, but pin past it). Similarly, large projects hit sync-export size limits — pull/push must degrade to async export, not fail the pipeline.Ramp-up
The API is easy — trans() with a locale is a fifteen-minute concept for any Laravel developer, and the mental model is close enough to __() that adoption isn't the bottleneck. The real learning curve sits with content and localization teams, not engineers: ICU message syntax, XLIFF structure, domain strategy, and the difference between a missing key and a fallback hit. Plan for a written style guide, a message linter in CI, and one training session with the translators before the first provider sync. Engineer ramp-up: ~1 week to first non-trivial integration, ~3–4 weeks to own the loader/formatter wiring and the parity test harness in posture A/B.
How can I help you explore Laravel packages today?