Skip to content

Scoped entity attributes with multi-axis hierarchical fallback #75

@michalbiarda

Description

@michalbiarda

Problem

There's no first-class way in Marko to store and resolve per-context values on entity properties, e.g. translated product names, region-specific prices, per-store labels, locale-aware copy, A/B variants.

Proposed Solution

Three new packages that deliver scoped attributes as a framework-supported feature:

  • marko/scope — driver-agnostic core.
  • marko/scope-mysql — MySQL / MariaDB driver.
  • marko/scope-pgsql — PostgreSQL driver.

Core capabilities (marko/scope):

  • #[Scoped(axes: [...])] attribute marks an entity property as scoped along one or more named axes (e.g. locale, store).
  • Axes are declared in scope.axes config with a dotted-path hierarchy — e.g. ['en', 'en.gb', 'en.us']. A ScopeRegistry (PhpScopeRegistry) reads the config and exposes the axis catalog.
  • ScopeContext::in($axis, $path) sets the ambient scope per request / CLI command / queue job, with clearAll() between long-running cycles.
  • ScopeResolver::resolved($entity, $property) reads the property with walk-up fallback: en.gben → the entity's default column value.
  • ScopeResolver::resolvedAt($entity, $property, $scope) resolves at an explicit Scope without touching ambient context.
  • ScopeResolver::setOverride() / clearOverride() write per-scope values.
  • Storage via the HasScopes trait + HasScopesInterface (applied directly on the entity, or on a companion class) — overrides are JSON-serialized into a single scopes column. ScopedDataSerializer handles BackedEnum and DateTimeImmutable round-trips.
  • ScopedOrderByFactory produces a QuerySpecification that emits driver-specific SQL walking the same hierarchy, so ORDER BY name automatically sorts by the resolved value.
  • ScopedEntityValidator runs at boot and rejects scoped entities that have no storage configured (clear, actionable error).
  • Value objects: Scope (axis:path), ScopeHierarchy (path tree with walkUp), ScopeMetadata / ScopeMetadataFactory (reflection-based, cached).

Driver packages add:

  • marko/scope-mysqlScopeSortRendererInterface impl emitting JSON_EXTRACT/JSON_UNQUOTE over a json column, plus a migration helper.
  • marko/scope-pgsql — same contract emitting jsonb operators (->, ->>), plus a migration helper.

Both drivers register through marko/database's existing orderByRaw interface, so the resolver, registry, and config layers stay driver-agnostic.

Alternatives Considered

Alternatives Considered

  • Per-axis side tables (product_translations, product_prices_by_store, …): tight coupling to specific axes, schema bloat as axes multiply, JOIN on every read, no shared fallback logic.
  • Plain JSON column with no framework support: every consumer reinvents the walk-up fallback and the ORDER BY rendering; high risk of divergence.
  • EAV: heavy, loses type safety, slow on reads, and still doesn't solve the fallback / sort problems.
  • Per-property bespoke types (e.g. a TranslatedString value object): doesn't generalize beyond translation; doesn't compose across axes (locale × store).

Package

database

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions