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.gb → en → 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-mysql — ScopeSortRendererInterface 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
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).scope.axesconfig with a dotted-path hierarchy — e.g.['en', 'en.gb', 'en.us']. AScopeRegistry(PhpScopeRegistry) reads the config and exposes the axis catalog.ScopeContext::in($axis, $path)sets the ambient scope per request / CLI command / queue job, withclearAll()between long-running cycles.ScopeResolver::resolved($entity, $property)reads the property with walk-up fallback:en.gb→en→ the entity's default column value.ScopeResolver::resolvedAt($entity, $property, $scope)resolves at an explicitScopewithout touching ambient context.ScopeResolver::setOverride()/clearOverride()write per-scope values.HasScopestrait +HasScopesInterface(applied directly on the entity, or on a companion class) — overrides are JSON-serialized into a singlescopescolumn.ScopedDataSerializerhandlesBackedEnumandDateTimeImmutableround-trips.ScopedOrderByFactoryproduces aQuerySpecificationthat emits driver-specific SQL walking the same hierarchy, soORDER BY nameautomatically sorts by the resolved value.ScopedEntityValidatorruns at boot and rejects scoped entities that have no storage configured (clear, actionable error).Scope(axis:path),ScopeHierarchy(path tree withwalkUp),ScopeMetadata/ScopeMetadataFactory(reflection-based, cached).Driver packages add:
marko/scope-mysql—ScopeSortRendererInterfaceimpl emittingJSON_EXTRACT/JSON_UNQUOTEover ajsoncolumn, plus a migration helper.marko/scope-pgsql— same contract emittingjsonboperators (->,->>), plus a migration helper.Both drivers register through
marko/database's existingorderByRawinterface, so the resolver, registry, and config layers stay driver-agnostic.Alternatives Considered
Alternatives Considered
product_translations,product_prices_by_store, …): tight coupling to specific axes, schema bloat as axes multiply, JOIN on every read, no shared fallback logic.TranslatedStringvalue object): doesn't generalize beyond translation; doesn't compose across axes (locale × store).Package
database