Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Doctrine Translatable Bundle Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. composer require runroom-packages/doctrine-translatable-bundle
  2. Enable the bundle in config/bundles.php (Symfony flex usually does this automatically).
  3. Create or modify the root entity: implement TranslatableInterface, use TranslatableTrait, and declare a OneToMany association to your translation entity with cascade: ["persist"] and orphanRemoval: true.
  4. Create the translation entity: implement 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).
  5. Run bin/console doctrine:schema:update --dump-sql (or make:migration/doctrine:migrations:diff) to verify the mapping is what you expect.
  6. First use case: in a controller or Twig template, call $entity->translate($request->getLocale()) to fetch the TranslationInterface for the current request. That's the primary API you'll touch daily.

Implementation Patterns

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.

Gotchas and Tips

  • Unique constraint is not optional. Always add a 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.
  • Owning side matters. The translation owns the FK (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 changes on the root entity. Locale belongs on the translation, not the root. A locale field on the root (common in older codebases) makes this bundle fight you; refactor it out.
  • Lazy loading on lists. 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.
  • Schema changes on translation entities are effectively schema changes on the root in terms of migration noise (indexes, FKs). Review doctrine:migrations:diff output rather than trusting --force on a fresh setup.
  • Extension point. If you need extra behavior on top of the traits (e.g. a 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin