symfonycasts/object-translation-bundle
A lightweight Symfony bundle for translating Doctrine entities and other objects without extra translation tables. Store translations in a JSON column via a simple trait and interface, then read and set locale-aware values directly on your objects.
This is a Symfony bundle (SymfonyCasts/object-translation-bundle), not a Laravel package. There is no service provider, no php artisan vendor:publish, and no Eloquent support. Before writing any code, decide which side of that line you're on:
laravel-doctrine/orm). The bundle's Bundle class and DI extension assume Symfony's kernel and bundle registration, so you'd be hand-wiring the subscriber into Doctrine's event manager. For most Laravel teams the pragmatic answer is to borrow the pattern (a JSON translations column plus a Doctrine/SQL listener) rather than install the bundle.Minimum viable setup (Symfony):
composer require symfonycasts/object-translation-bundleconfig/bundles.php.json column (e.g. translations) to the entity's table — MySQL 5.7+/8, Postgres jsonb, or SQLite all work.LocaleListener / _locale); the bundle resolves the active locale from there when hydrating.Where to look first: the bundle's source src/ directory — the Doctrine event subscriber is the whole package, and reading it end-to-end takes ten minutes and answers 90% of the questions you'll have. Second, the test suite, which doubles as the most accurate spec for edge cases (missing translation, fallback locale, flush behaviour).
First use case: a Product entity whose name and description differ per locale. Editors fill in translations in an admin form; the storefront renders whichever locale the request is in, with a fallback when a translation is absent.
JSON column as the storage unit. All locales live in one column as {"en": "...", "fr": "..."}. This keeps the schema stable as you add locales and makes "give me every translation" a single row read — which is exactly what an admin edit screen needs.
Locale switching without touching the entity. The idiomatic flow is: set the locale on the request, let the listener hydrate the right value. For admin screens where you need to write every locale at once, that's the exception — you'll typically expose the raw translation array (or a setTranslations()-style hook) and bypass the locale-resolved getter. Keep those two access paths clearly separated in your code or you'll confuse yourself later.
Fallback chain. Configure a fallback locale (usually the app's default) and treat "translation exists but is an empty string" as missing, not as a translated value. Decide this once, globally; it's the single most common source of "why is the site blank in German" tickets.
Editing all locales in one form. Standard pattern: a form with one tab per locale, backed by a translations[locale][field] array. Validate per-locale required fields in a constraint or form event rather than on the entity, since the entity only ever sees one locale at a time.
Read-heavy API output. Don't serialize the whole translations array into public JSON. Expose a resolved value for the current locale and, if you need it, a nested translations object only on admin endpoints.
Testing. Set the locale explicitly in the test (request header, or the locale setter on your test client) and assert both the translated and fallback paths. Add one test that flushes a translation change and re-reads it from a fresh entity manager — that round-trip is where listener-based translation packages usually break.
Version 1.1.0 specifically: this release is dependency-only — doctrine/doctrine-bundle v3 compatibility and Symfony 8 support. No API changes. If you're on it, nothing in your application code moves. If you're pinned to Symfony 6.4/7.x and DoctrineBundle 2.x, you can upgrade at leisure; the constraints were widened, not tightened.
Framework mismatch is the big one. Any tutorial, Stack Overflow answer, or LLM suggestion that pairs this with Eloquent, HasTranslations traits, or app/Providers is wrong for this package. Verify every snippet against the bundle's own src/ before adopting it.
Dirty tracking on flush. Doctrine's change detection compares the mapped JSON column. If your code mutates a translation array in place on an already-managed entity, the unit of work may not see it as changed. Either reassign the whole array (so the reference changes) or explicitly recompute before flush(). Symptom: flush() succeeds, nothing is written, no error.
Locale is resolved at hydration/flush time, not at call time. If you change the locale mid-request and then read a translated property, you may get stale data from an already-loaded entity. Reload the entity, or design so locale is fixed for the request's lifetime.
JSON column type drift. A text column instead of json/jsonb mostly works but loses validation and indexability. If you need to query by translation ("find products whose French name contains X"), you're outside what a JSON-per-row design does well — reach for a generated column, a JSON path index, or a separate translations table.
Empty string vs. null vs. absent key. Three different states, three different behaviours depending on your fallback config. Normalise on write (store null for "not translated") and treat '' as untranslated on read.
Debugging. Doctrine events are invisible in the profiler's default view — use the Doctrine panel's query list plus a dd() inside the subscriber's postLoad/onFlush to confirm it's actually attached. If translations never resolve, the subscriber probably isn't registered at all (a very common Laravel-on-Doctrine failure mode), and every symptom looks like a mapping problem instead.
Extension points. The attribute marker is only a marker; if you need per-field fallbacks, HTML sanitisation of translated content, or a translation cache, wrap the getter in your own service rather than subclassing the subscriber. The subscriber is small but not designed for decoration.
DoctrineBundle v3 note. If you're upgrading to doctrine/doctrine-bundle v3 in the same pass, that migration itself has breaking changes (config key renames, removed aliases). Do the DoctrineBundle upgrade as its own commit before bumping this package, so a failure has one obvious cause.
How can I help you explore Laravel packages today?