runroom-packages/doctrine-translatable-bundle
Doctrine Translatable Bundle adds per-locale translations to Doctrine entities in Symfony, storing translated fields in separate translation tables and wiring them into queries and hydration. Simple setup for building multilingual applications.
The bundle is a thin layer over Doctrine for attaching per-locale translation rows to an entity. It expects you to define two entities (the translatable root and a translation child) and to wire them together with the interfaces/traits the bundle ships. The README in the repo is the only source of truth for the exact class namespace (Runroom\DoctrineTranslatableBundle\...) — read it first, then look at the traits (TranslatableTrait, TranslationTrait) and interfaces (TranslatableInterface, TranslationInterface) since those dictate the shape of your entities.
Minimal setup:
composer require runroom-packages/doctrine-translatable-bundleconfig/bundles.php (Symfony flex usually does this automatically).TranslatableInterface, use TranslatableTrait, and declare a OneToMany association to your translation entity with cascade: ["persist"] and orphanRemoval: true.TranslationInterface, use TranslationTrait, add ManyToOne back to the root, and store the actual translatable fields there (title, body, slug, etc.). Constrain uniqueness on (translatable_id, locale).bin/console doctrine:schema:update --dump-sql (or make:migration/doctrine:migrations:diff) to verify the mapping is what you expect.$entity->translate($request->getLocale()) to fetch the TranslationInterface for the current request. That's the primary API you'll touch daily.Entity shape. The standard pattern is one root table + N translation rows. Keep non-localized fields (created_at, enabled, position) on the root, and everything a translator would edit on the translation row. This makes joins predictable and lets you filter by locale without touching the root.
Creating translations. New entities arrive with an empty ArrayCollection of translations. Seed one translation per configured locale in a factory/fixture, or lazily add on the first edit. Because cascade: persist is on the association, persist($root) will persist translations you added via addTranslation() — no separate persist() call.
Selecting a locale in queries. For listings you almost always want a single locale joined in, not the whole collection:
$qb = $repo->createQueryBuilder('p')
->leftJoin('p.translations', 'pt', 'WITH', 'pt.locale = :locale')
->setParameter('locale', $locale);
The bundle's trait gives you getTranslations() and translate($locale); the latter works off the in-memory collection, so it's cheap after hydration, but on a collection of 50 products it triggers lazy loads. Prefer the explicit JOIN ... WITH above for grids and APIs, and reserve translate() for single-entity pages.
Twig helper. A small Twig extension wrapping translate(app.request.locale) with a fallback to the default locale is the typical integration point:
{{ product.translate(app.request.locale).title ?? product.translate('en').title }}
Forms and admin. Model the translation as a sub-form (CollectionType or a single embedded form for the currently-edited locale) and let the trait's addTranslation() handle the plumbing. If you use Sonata Admin, the pattern is ->add('translations', CollectionType::class, ['by_reference' => false]) so the collection is mutated through the setter rather than the array reference — otherwise Doctrine won't see the diff.
API payloads. Serialize translations as a nested map ({"en": {...}, "fr": {...}}) by normalizing getTranslations() keyed by getLocale(). This keeps clients from needing to know the entity shape.
Fallback resolution. Decide and codify a fallback policy early (requested locale → default locale → first available). Do it in one service (TranslationResolver) and call it from Twig, API controllers, and admin alike — do not scatter ?? 'en' around the codebase.
uniqueConstraints entry on (translatable_id, locale). Without it, re-saving a form easily produces duplicate rows per locale, and translate() silently returns the first one.by_reference => false on collection forms. Symfony's collection type defaults to mutating the underlying ArrayCollection by reference, which bypasses Doctrine's change tracking. Set by_reference => false and make sure addTranslation/removeTranslation update both sides of the association.ManyToOne on translatable), the root is inversedBy. If you flip this you'll fight orphan removal and cascade behavior forever.orphanRemoval: true + cascade persist is the combination that actually deletes translations when the root is removed or a translation is dropped from the collection. Leave cascade: remove off — orphan removal covers it and avoids double-delete surprises.locale field on the root (common in older codebases) makes this bundle fight you; refactor it out.getTranslations() will N+1 on collection pages. Either join with WITH pt.locale = :locale as shown, or fetch-join all translations when you truly need every locale. Watch the generated SQL — this is the single biggest performance trap.translate() return type. Confirm in the README whether it returns ?TranslationInterface or creates an empty translation on demand. Code defensively (?->getTitle()) until you've verified; the difference matters for null handling in controllers.doctrine:migrations:diff output rather than trusting --force on a fresh setup.getTranslationOrCreate($locale) helper), add a small abstract base class in your app that extends the traits rather than editing vendor code — the bundle is [READ-ONLY] split from the Runroom monorepo, so contributions go upstream there, not in this repo.How can I help you explore Laravel packages today?