Skip to content

Translation

phil/doctrine-behaviours
TypeKindRole
Contract\TranslatableinterfacetranslationClass(), translatedFields(), newTranslation()
Contract\Translationinterfacetranslatable(), locale()
TranslationEntitytraitThe column locale, in the one spelling of phil/locale-dimensions
TranslationListenerlistener: loadClassMetadataChecks the mapping, adds the unique index on the record and the locale
Translationsservicefind(), get(), translate(), locales(), preload(), values()
TranslationSetvalueThe translations preload() read, by record
Exception\InvalidTranslationexceptionA translation entity mapped otherwise, or a translation made for another record

A translated record keeps what every locale shares — a price, a date, an image — and its text lives on a translation entity of its own, one row per record and locale, with only the translated columns. The locales, their spelling and the fallback chains are those of phil/locale-dimensions.

#[ORM\Entity]
class Product implements Translatable
{
    public static function translationClass(): string { return ProductTranslation::class; }
    public static function translatedFields(): array { return ['name', 'description']; }

    public function newTranslation(Locale $locale): ProductTranslation
    {
        return new ProductTranslation($this, $locale);
    }
}

#[ORM\Entity]
class ProductTranslation implements Translation
{
    use TranslationEntity;

    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private ?int $id = null;

    #[ORM\Column]
    public string $name = '';

    #[ORM\Column(type: 'text', nullable: true)]
    public ?string $description = null;

    public function __construct(
        #[ORM\ManyToOne(targetEntity: Product::class)]
        #[ORM\JoinColumn(name: 'product_id', nullable: false, onDelete: 'CASCADE')]
        private Product $translatable,
        Locale $locale,
    ) {
        $this->writtenIn($locale);
    }

    public function translatable(): Product { return $this->translatable; }
}
$translations = new Translations($manager);
$chain = $locales->chainFor(Locale::fromString('fr_CA'));   // fr_CA → fr → en

$translation = $translations->find($product, $chain);        // the fr_CA row, or fr, or en, or null
$translations->values($translation);                         // ['name' => '…', 'description' => '…']

The mapping

The translation entity maps the to-one relation translatable to its record, held by one join column, and the field locale, which TranslationEntity gives. As the mapping is loaded, TranslationListener refuses a translation entity without either, with InvalidTranslation, and adds to its table a unique index on the join column and the locale, named <table>_translatable_locale: one row per record and locale, the only thing that holds when two requests translate the same record at once. An index the entity declares on the same two columns, in either order, is kept as it is. A subclass inherits both from the class that maps them.

The record has no collection of its translations: a list of records would load every locale of each to show one. Translations reads the rows it needs.

Reading

MethodReturns
find($record, FallbackChain)the row of the first locale of the chain that has one, or null
get($record, Locale)the row of exactly that locale, or null
locales($record)the locales the record has a row in, ordered by their value: what hreflang alternates list
preload(iterable $records, FallbackChain)a TranslationSet: of($record) gives each record's row along the chain
values(Translation)the fields translatedFields() names, by name, in that order

A row is served whole. The chain chooses a row, never a field: a French row with no description is served without one, rather than with the English description beside a French name. A chain that runs out serves nothing, and the caller decides what that means — a 404, a placeholder, a page that says the text is not translated.

A list is one query per translation class. preload() reads the rows of all the records, in all the locales of the chain, with one query per translation class and per 500 records (Translations::CHUNK), then picks each record's row in memory. Read the list first, preload, then render:

$products = $repository->findBy(['category' => $category]);
$set = $translations->preload($products, $chain);

foreach ($products as $product) {
    $set->of($product)?->name;
}

Rows persisted and not flushed count, so a record translated in this request is read back before the flush; rows scheduled for removal do not. A record that is not stored yet reads nothing from the database.

Writing

translate($record, Locale) gives the row of exactly that locale, to be written: the stored one, or a new one made by newTranslation() and persisted for the next flush. It never copies a field from another locale. The text a reader would have been served through the chain is not the text of this locale, and a copy would pass for a translation that nobody wrote, then drift from its source without anyone seeing it. An editor that wants to start from another locale shows that locale beside the empty fields.

newTranslation() must make a translation of this record, of the class translationClass() names, in the locale asked; anything else raises InvalidTranslation. The record and the locale of a row are given by its constructor and never change.

Slugs

A translation entity that is Sluggable takes ScopedSluggableEntity and names locale in slugScope(), so that each locale has its own slugs: /fr/crepes and /en/crepes coexist, and two French rows titled alike get crepes and crepes-2. SlugListener refuses the slug of a translation whose scope does not name its locale, with InvalidScope. Declare the unique index on the scope and the slug:

#[ORM\UniqueConstraint(columns: ['locale', 'slug'])]
class ProductTranslation implements Translation, Sluggable
{
    use TranslationEntity;
    use ScopedSluggableEntity;

    public function slugSource(): string { return $this->name; }
    public function slugScope(): array { return ['locale']; }
}

Versions

There is no stage and no copy of a source locale: a translation that changes over time is Versionable, as any record is, and each locale has its own versions — frozen, forked and restored on its own by Versions. The record's shared columns are versioned on the record, if at all. See Versions.