Skip to content

feat: extend existing entity tables via #[Table(extends:)] #63

@markshust

Description

@markshust

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 options
are:

  1. Edit the original entity class (violates true modularity)
  2. Preference-subclass it (changes class identity, only one preference wins)
  3. 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.
  • Smaller surface. Estimated ~300–500 lines vs the parallel-system
    approach (~3.3k lines in feat(entity-extension): add entity extension system #59).

Behavior

  • 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

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions