You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Third-party and local modules need to add columns to another module's entity
table without touching the original entity class. Today the schema is derived
directly from #[Column] attributes on entity properties, so the only options
are:
Edit the original entity class (violates true modularity)
Preference-subclass it (changes class identity, only one preference wins)
Write a raw migration the diff machinery doesn't know about (next migrate:diff would try to drop the columns)
We need a first-class, Marko-friendly way to extend an existing entity's
table.
Proposal
Extend the existing #[Table] attribute with an extends: parameter. An
extending entity is a normal Entity subclass that targets an existing
table and contributes additional columns to it. The table name is derived
from the parent — no need to repeat it.
#[Table(extends: WebhookAttempt::class)]
class WebhookAttemptAnalytics extends Entity
{
#[Column]
public ?string$traceId = null;
#[Column]
public ?int$retryReason = null;
}
The extends: param is parallel to #[Preference(replaces:)] — both are
verbs describing what the attribute does to the target class.
Why this shape
One concept, not two. Extenders are just entities — they reuse EntityDiscovery, EntityMetadataFactory, EntityHydrator, and Repository instead of growing a parallel EntityExtension hierarchy.
More flexible. Extenders can have their own indexes, relationships,
and lifecycle hooks — all the existing entity machinery works for free.
Schema: at registration, the extender's columns are merged into the
parent's Table in SchemaRegistry. migrate:diff sees one table with
all columns from all registered modules.
Validation (loud at boot): an extender may not declare its own name: on #[Table] (table comes from the parent), may not redeclare
the parent's PK, and may not mark a column autoIncrement.
Conflict detection: if two modules add a column with the same name,
fail loudly at boot with a clear error pointing to both classes.
Hydration: when loading the parent, the hydrator instantiates all
registered extenders from the same row in a single SELECT. Extenders
whose columns are absent (rolling deploy) are silently skipped.
Write path:Repository::save() on the parent merges columns from
registered extenders into a single INSERT/UPDATE. Extenders can also be
saved independently via their own repository.
Access:Entity::companions() (or similar small helper) returns the
hydrated extender objects keyed by class. IDE inference via @template.
Out of scope
Cross-table extensions (separate table with FK) — that's a normal
relationship; not what this is solving.
Removing columns added by another module — modules own their own columns.
Implementation sketch
#[Table]: add extends: ?class-string $extends = null param; name: becomes optional when extends: is set
EntityMetadata: add extends: ?string and extenders: array fields
EntityMetadataFactory: validate extension constraints, expose getExtendersOf(class-string) lookups; resolve table name from parent
when extends: is set
SchemaRegistry::registerEntity(): when extends is set, merge columns
into the parent's existing Table rather than creating a new one
EntityHydrator: hydrate registered extenders from the same row;
silently skip missing columns
Repository: INSERT/UPDATE merge columns from extenders
Entity: small companions() accessor for the hydrated extender bag
Problem
Third-party and local modules need to add columns to another module's entity
table without touching the original entity class. Today the schema is derived
directly from
#[Column]attributes on entity properties, so the only optionsare:
migrate:diffwould try to drop the columns)We need a first-class, Marko-friendly way to extend an existing entity's
table.
Proposal
Extend the existing
#[Table]attribute with anextends:parameter. Anextending entity is a normal
Entitysubclass that targets an existingtable and contributes additional columns to it. The table name is derived
from the parent — no need to repeat it.
The
extends:param is parallel to#[Preference(replaces:)]— both areverbs describing what the attribute does to the target class.
Why this shape
EntityDiscovery,EntityMetadataFactory,EntityHydrator, andRepositoryinstead of growing a parallelEntityExtensionhierarchy.and lifecycle hooks — all the existing entity machinery works for free.
approach (~3.3k lines in feat(entity-extension): add entity extension system #59).
Behavior
parent's
TableinSchemaRegistry.migrate:diffsees one table withall columns from all registered modules.
name:on#[Table](table comes from the parent), may not redeclarethe parent's PK, and may not mark a column
autoIncrement.fail loudly at boot with a clear error pointing to both classes.
registered extenders from the same row in a single SELECT. Extenders
whose columns are absent (rolling deploy) are silently skipped.
Repository::save()on the parent merges columns fromregistered extenders into a single INSERT/UPDATE. Extenders can also be
saved independently via their own repository.
Entity::companions()(or similar small helper) returns thehydrated extender objects keyed by class. IDE inference via
@template.Out of scope
relationship; not what this is solving.
Implementation sketch
#[Table]: addextends: ?class-string $extends = nullparam;name:becomes optional whenextends:is setEntityMetadata: addextends: ?stringandextenders: arrayfieldsEntityMetadataFactory: validate extension constraints, exposegetExtendersOf(class-string)lookups; resolve table name from parentwhen
extends:is setSchemaRegistry::registerEntity(): whenextendsis set, merge columnsinto the parent's existing
Tablerather than creating a new oneEntityHydrator: hydrate registered extenders from the same row;silently skip missing columns
Repository: INSERT/UPDATE merge columns from extendersEntity: smallcompanions()accessor for the hydrated extender bagReferences
pipeline.