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.
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
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);
@Prezent\Translatable and use TranslatableTrait (which implements TranslatableInterface).@Prezent\Translation, use TranslationTrait, mark the locale property @Prezent\Locale and the translated fields @Prezent\Translated.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.
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.
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.(translatable_id, locale). Nothing in the package enforces it; duplicate translations cause translate() to return an arbitrary one and behave non-deterministically.$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.postLoad. For any index/list page, add the TranslationWalker hint or select the translations explicitly.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.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.postLoad. Nine times out of ten the locale is wrong, the fallback is unset, or the walker hint is missing — not the mapping.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.How can I help you explore Laravel packages today?