v5.1.0
Translation roots — one non-translatable row per object that owns the tuuid — and the command
that creates them for rows that predate them. Additive on 5.0: no default changes, no
configuration key, an application without roots is unchanged. One existing throw path changes
behaviour (under Fixed). Migration:
UPGRADING.md § UPGRADE FROM 5.0 to 5.1.
Added
TranslationRootInterfaceandTranslationRootTrait(Doctrine\Model): the root's
identity contract —mintTuuid()for a brand-new object,adoptTuuid()for taking over an
existing group's identity (same value again is a no-op, a different one throws),getTuuid()
that never lazily mints — and a uniquetuuidcolumn. A root is deliberately not
TranslatableInterface(no locale, no listeners) and not PHPreadonly(Doctrine's
ReflectionReadonlyPropertycompares by identity).#[TranslationRoot](Doctrine\Attribute): optional marker for a translation row's root
reference. The reference is recognised structurally — a#[ORM\ManyToOne]whose declared
type implements the interface,is_a()on the name and never gated behindclass_exists()—
so a forgotten attribute cannot silently clone the root, and no pre-5.1 class can qualify by
accident.AttributeHelper::isTranslationRootReference(),translationRootType(),
hasTranslationRootMarker()andisEffectivelyShared()(attribute OR root reference).translate()reaffirms a root reference to the identical root instance on every clone; a
nullreference (phase 1) staysnull.- Compile-time contract checks in
AttributeValidationPass, each a
TranslationRootContractExceptionwith aSolution:line: at most one root reference per
class; the type must not also be translatable; not#[ORM\Id], not#[EmptyOnTranslate], no
unique join column; the marker only on a real root reference; and, once the reference is
non-nullable, a constructor that requires the root. OneAttributeHelperper run, so a
property declared on an abstractSINGLE_TABLEancestor is reported once, the constructor rule
once per concrete leaf. The pass publishestmi_translation.translation_root_classes. tmi:translation:adopt-root(--dry-run,--check,--entity): streams each adopter's
hierarchy root and classifies every group before writing —mismatched,new,complete,
partial,drift,ambiguous. Write mode aborts before the first write while any group is
mismatched, drifted or ambiguous; otherwise adopts in batches of whole groups (anewgroup's
root adopts the group'stuuid; apartialgroup is healed onto its existing root; an
interruption never commits a half-attached group).--checkfails on anything butcomplete,
on roots without rows (oneNOT EXISTSquery per root reference, locale filter suspended;
NOT INbanned by name), and on any orphan counter above zero — every counter printed at 0
too, an exception shown asERRORand failing.--entitywith a concrete leaf still streams
the hierarchy root.- Extension points:
RootAdopterInterface(tagtmi_translation.root_adopter, required
attributeclass) collected byRootAdopterPassintoRootAdopterRegistryand cross-checked
at compile time — exactly one adopter per root-declaring class, no adopter for a class without
one, tag andgetTranslatableClass()must agree;TuuidOrphanCounterInterface(tag
tmi_translation.tuuid_orphan_counter) collected byTuuidOrphanCounterPassinto
RootCheckAggregator;RootAdoptionExceptionfor a factory result that already carries a
tuuidor is of the wrong class. SharedValueSyncReport::rootDrift()(third, optional constructor argument) and theroot
key on everySharedValueSynchronizer::sharedProperties()entry.
Changed
SharedValueSynchronizerdiscovers a root reference as a shared association and compares it
by identity, but never writes it: a mismatch between siblings is reported asrootDrift(), in
write mode and compare mode alike.SharedValuePropagationListenerneither propagates nor
conflict-checks a root reference path.tmi:translation:sync-sharedlists such drift as not
writable, namestmi:translation:adopt-root --check, and exits non-zero in every mode.
SharedDriftScanneryields it withisReadonly() === true.
Fixed
BidirectionalManyToOneHandlerno longer throws for a#[SharedAmongstTranslations]
ManyToOneback-reference reached through its parent'sOneToMany(an
ItineraryDay::$itinerary): the parent was untranslatable the moment a child declared its
back-reference shared, for an attribute that changes nothing about the outcome. The flag is
consumed, the child is cloned through the full pipeline and its back-reference resolves to
the parent's clone. The direct form — a shared association declared on another owning
class — still throws. This is a behaviour change on an existing throw path; no consumer relied
on it.