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 Laravel Package

prezent/doctrine-translatable

Doctrine ORM extension that makes entities translatable: implement a simple interface, attach a listener, and store per-locale translations in a companion table. Fetch localized field values automatically based on the active locale, with fallback support for missing translations.

Deep Wiki
Context7

Getting Started

This is a small, focused library for the entity-translation pattern: translated fields live in a separate *Translation entity joined back to the root entity, one row per locale. It was inspired by Gedmo's Translatable but is lighter and less opinionated, and it is Doctrine ORM 2.x / annotation-config only — no PHP attributes, no Doctrine 3 support.

Minimal wiring:

composer require prezent/doctrine-translatable
  1. Register the listener on the entity manager's event manager (or as a doctrine.event_subscriber service in Symfony):
$translationManager = new Prezent\Doctrine\Translatable\TranslationManager();
$translationManager->addTranslation(Article::class, ArticleTranslation::class);

$listener = new Prezent\Doctrine\Translatable\TranslatableListener($translationManager);
$listener->setCurrentLocale('nl_NL');
$listener->setFallbackLocale('en_US');

$em->getEventManager()->addEventSubscriber($listener);
  1. Annotate the root entity with @Prezent\Translatable and use TranslatableTrait (which implements TranslatableInterface).
  2. Annotate the translation entity with @Prezent\Translation, use TranslationTrait, mark the locale property @Prezent\Locale and the translated fields @Prezent\Translated.
  3. Run schema:update / migrations — you get one extra table per translated entity.

First use case to prove it works: a single Article with title and body, query it by ID, then flip setCurrentLocale() and re-fetch. If you see the right title in both locales and no extra SELECTs beyond one per translation row, the wiring is correct.

Where to look first: the README's entity examples, then TranslatableListener and Query\TranslationWalker in src/. Everything else is traits and annotation classes.

Implementation Patterns

Trait-based entities (the happy path). Root entity:

/** @Prezent\Translatable */
class Article implements TranslatableInterface
{
    use Prezent\Doctrine\Translatable\Entity\TranslatableTrait;

    /** @OneToMany(targetEntity="ArticleTranslation", mappedBy="translatable",
     *             cascade={"persist", "remove"}, orphanRemoval=true) */
    protected $translations;
}

Translation entity:

/** @Prezent\Translation */
class ArticleTranslation implements TranslationInterface
{
    use Prezent\Doctrine\Translatable\Entity\TranslationTrait;

    /** @Prezent\Locale */
    protected $locale;

    /** @Prezent\Translated */
    protected $title;

    /** @Prezent\Translated */
    protected $body;
}

The __call proxy. TranslatableTrait forwards unknown method calls to translate(), so $article->getTitle() / $article->setTitle(...) operate on the current-locale translation. This is what makes the extension feel transparent — but it also means typos become runtime BadMethodCallExceptions rather than missing-method errors at parse time.

Locale handling. Use $article->translate('fr_FR') to fetch a specific translation, or translate() with no argument for the listener's current locale (creating an empty one if needed). translateTo() switches the entity's own current locale. A fallback locale is consulted when the requested translation doesn't exist yet.

Query-level hydration. For lists, always add the walker hint so translations are joined in rather than lazily loaded per row:

$query = $em->createQuery('SELECT a FROM Article a');
$query->setHint(Query::HINT_CUSTOM_OUTPUT_WALKER,
    Prezent\Doctrine\Translatable\Query\TranslationWalker::class);

There are corresponding hints for enabling/disabling the fallback at query level — set them per query rather than relying on listener defaults in read-heavy screens.

Form integration. The standard Symfony pattern works well: an ArticleType with a CollectionType of ArticleTranslationType, rendered as per-locale tabs keyed by locale. Add a uniqueness validator on (translatable, locale) rather than relying on the DB alone, so you get a form error instead of a UniqueConstraintViolationException.

Locale switching mid-request. Setting the listener locale only affects entities loaded afterwards. For an admin "preview as locale" toggle, change the locale and re-query; don't expect already-hydrated entities to re-resolve.

Gotchas and Tips

  • The listener holds mutable global state. setCurrentLocale() is request-scoped by design, but in long-running workers (Messenger, RoadRunner, Swoole) a stale locale leaks into the next job. Reset it explicitly at the start of every message handler.
  • $translations collection needs cascade: persist and orphanRemoval: true. Without orphan removal, removing a locale tab in a form leaves orphan rows that will fail the uniqueness check on the next save.
  • Add a unique index on (translatable_id, locale). Nothing in the package enforces it; duplicate translations cause translate() to return an arbitrary one and behave non-deterministically.
  • The proxy is silent about missing translations. $article->getTitle() on a locale with no translation returns null (or an empty new translation), not an error. If your UI requires completeness, validate explicitly with getTranslation($locale) !== null.
  • DQL without the walker N+1s. Every entity in a result set triggers its own translation load on postLoad. For any index/list page, add the TranslationWalker hint or select the translations explicitly.
  • Annotations only. The mapping is driven by Doctrine\Common\Annotations, so on PHP 8 you'll need doctrine/annotations retained and #[...] attributes will be ignored. This is the main reason to consider alternatives on greenfield projects.
  • The TranslationManager is the extension point. If you don't want the trait's naming conventions, register your own translation classes there; the listener resolves metadata from that map rather than from the association only.
  • Debugging tip: when translations "disappear," log the listener's current locale in postLoad. Nine times out of ten the locale is wrong, the fallback is unset, or the walker hint is missing — not the mapping.
  • Watch Doctrine version drift. Pin doctrine/orm to a 2.x range you've tested; the package predates the 2.x event-system deprecations and the Doctrine 3 removal of annotation drivers.
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.
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect
youbar/easy-crud
c-point-labs/laravel-hostyourai
dbravoan/dbcms-admin-theme-dbcms
dbravoan/dbcms-theme-db-cyber
dbravoan/dbcms-blocks-basic
dbravoan/dbcms-module-content-audit
dbravoan/dbcms-module-ai-assistant
paygate-to/paygate-php
padosoft/askmydocs-connector-base
defstudio/supercharged-carbon
bernskioldmedia/laravel-matomo