Template Data Contract
This is the authoritative migration and ownership guide for data crossing the
PHP controller/renderer boundary. It applies to PHP Plates HTML rendering and
to ViewModel JSON negotiation from k0smos 0.26.0 onward.
Qualified SEO alternate rendering
SeoTemplateData::alternates is an optional locale-to-public-path map carried
by ViewModel::setSeoAlternates(), independently from navigation. The existing
core.seo contribution is its sole reserved-root owner in front HTML rendering.
Null/missing is unqualified; [] explicitly means none. Limits are 32 locales
and 2,000 UTF-8 bytes per path, with a safe root-relative URL and only a normalized
page=N query. Domains, fragments, arbitrary queries, double encoding, control
characters and dot segments cannot enter the declared contract. Error responses
publish none. Owner services preflight unsupported locale inventories and keep
usable content responses intact; strict setter validation remains a programming
contract.
All eight public heads use front/_partials/_seo_alternates from Default.
They require at least two members, self, a matching absolute canonical and no
noindex/none directive. Hrefs use the same public base authority; x-default
requires a qualified default member. PC3 derives og:locale:alternate from this
same group. The language selector retains its broader navigation map, including
fallbacks. Page and archives remain unqualified without an owner-proven family.
Ecommerce now qualifies actual catalog/category slices selected from canonical
bounded SQL, in stable base-name/ID order, with an explicit localized name for
every rendered product and public category/native/source identity. Secondary card
fields retain fallback; this does not certify complete editorial translation.
Incomplete slices and noindex views publish none. Two qualified nondefault
members can form a group without x-default. The synthetic page-two head test
proves transport only; owned SQL/Kernel evidence belongs to Ecommerce.
Ecommerce's declared product payload additionally owns product_selection_path
and product_variant_paths: served no-JS/currency paths and selection-aware
navigation, separate from query-free product-family SEO alternates. Existing
core SEO bounds remain unchanged; variant queries do not enter seo.alternates.
The product payload's localized variant names and complete ProductGroup graph
remain Ecommerce-owned. Detail and search share a batched locale overlay; complete
family schema stays static during selection while navigation and visible controls
change. This pure builder and SQL helper add no shared core API, reserved root,
DI key or module dependency.
The bounded core-promotion decision below remains a transport decision.
Self membership, absolute URLs and reciprocity follow Google's localized-version guidance. The requirement for exact visible owner translation is this application's qualification policy, not a claim that Google forbids template-only localization.
Ecommerce profile recovery data
The existing Ecommerce overview payload owns optional profile_values and
profile_errors. These are escaped nonsecret form drafts and stable field codes,
separate from the stored customer used by the account header. The existing
password_csrf_token covers native profile POST. Password values never enter a
recovery draft; JSON refusal responses expose only reason/field codes. Canonical
and DM1 templates share translated field rendering and linked ARIA descriptions.
The pure validator and controller-specific JSON adapter remain module-owned;
there is no new core API, root owner or DI key. Input/trust checks do not imply
atomic profile/password persistence or address/import qualification.
Invariants
- A declared payload is immutable and implements
TemplatePayloadInterface; its owner is stable and appears in collision diagnostics. - Values normalize to
null, scalar values, backed-enum values, or arrays. Presentation value objects opt in throughTemplateValueInterface. Domain entities, repositories, services, containers, DB connections, and arbitrary objects are rejected before Plates or the serializer receives them. - Core shared roots are reserved by
ViewDataSchema. Page and module payloads may not claim them.TemplateDataComposernames both owners for duplicate roots; no array spread orarray_mergeis allowed to decide ownership. FrontViewDataProviderRegistryresolves providers from active runtime modules only. It applies the same reserved-root and duplicate-owner checks before the public SPA receives module data. Providers implementingFrontLayoutViewDataProviderInterfacebelong toprovideLayout()instead: the Kernel adds their data to front HTML layouts only, excluding admin/JSON and avoiding duplicate homepage contributions. An inactive module has no provider instance and therefore no payload.- HTML and JSON receive the same normalized shape for a declared payload. JSON
is not wrapped in the legacy HTML-only
datavariable. BaseViewDataAggregatorconsumes flash data throughSessionStore::pull(); rendering code never accesses$_SESSIONdirectly.- Template-slot parameters cannot shadow the renderer-owned
slotorslotContextvariables.
Framework Root Inventory
The executable inventory is ViewDataSchema::definitions(). Every entry
declares its PHP/presentation type, owner, sensitivity (public, internal, or
personal), mutability, and Default/Sober consumers. Tests fail if catalog keys
collide or an entry omits either bundled admin theme.
| Owner | Root variables | Sensitivity / mutability |
|---|---|---|
core.theme |
ver, theme, themeName, themeSide, assetBase, assets |
public, immutable |
core.base |
app, env, request, user, flash |
public/internal/personal; immutable except consumed-on-read flash |
core.render-profile |
features, ui |
internal/public, immutable |
core.public-settings |
social, company, klaro, frontContent, favicons |
public, immutable |
core.feature-flags |
featureFlags |
internal, immutable |
core.module-runtime |
activeModules |
internal, immutable |
core.theme-preference |
themePalette, themeMode |
personal, immutable |
core.navigation |
menu, menuChannel, footerMenu, navigation |
public/internal, immutable |
core.breadcrumbs |
breadcrumbs |
public, immutable |
core.locale |
locale_switcher, locale |
public, immutable |
core.tenant / core.spa |
tenant, initial |
internal, immutable |
core.seo |
seo |
public, immutable |
The favicons value contains ico, png, apple_touch_icon, regular PWA 192/512, and maskable PWA 192/512 URLs. A Media-managed family is all-or-nothing: if its module is inactive or the projected set is incomplete, all values fall back to the shared project icons. URL-only legacy tenants continue to resolve their configured values, with regular PWA icons also serving as their maskable fallback.
Request, user, tenant, locale, navigation, breadcrumb, flash, asset, and SEO
value objects live under src/Rendering/TemplateData. PageTemplateData is the
page-specific envelope when a more specific presentation DTO is not warranted.
Compatibility Boundary And Deprecation Window
LegacyTemplateDataAdapter is the only compatibility boundary. In 0.26.x, a
raw array passed to ViewModel remains a set of direct Plates variables and is
not recursively normalized; this preserves existing templates that have not
yet migrated. A declared payload is strict immediately. A top-level object is
not silently wrapped for HTML and fails with an actionable exception.
The migration window is:
0.26.x: all new/changed controllers and front providers use declared payloads; existing raw controller arrays remain supported.0.27.x: CI inventory may warn on raw arrays in touched controllers; module authors must migrate front-provider contributions.0.28.x: raw front-provider contributions are scheduled for rejection. Existing controller arrays remain behind the adapter.0.29.0retains the compatibility adapter and arbitrary controller arrays. Any later removal requires a separate release note and migration verification.
Module Migration
For a page, replace the raw array without changing the direct variables used by the template:
return (new ViewModel(new PageTemplateData(
'module.example.admin-overview',
['exampleStatus' => $status, 'exampleWritable' => $canWrite],
)))->setTemplate('example/overview', 'admin');
Choose module-specific roots (exampleStatus, blogFeed) rather than generic
core names (user, features, locale). If construction rejects an object,
map it at an application/presentation boundary; never loosen the normalizer.
For front integration, implement FrontViewDataProviderInterface and return a
TemplatePayload whose owner is the provider class. Register the provider only
through the module's FrontViewDataProviderModuleInterface contribution. Do
not place module providers in core DI definitions or instantiate inactive
modules to obtain view data.
Delivered Pilots
- Core admin settings overview: declared page payload; its former ambiguous
featurespage root is nowsettingsFeatures, avoiding the core render- profile root. - Blog homepage feed: declared active-module contribution under
blogFeed. - OneUptime admin overview/settings: declared page payload with scalar/array
values and the module-specific
oneUptimeLocaleroot.
Contract, collision, unsupported-object, legacy compatibility, session, JSON,
active/no-module, and Default/Sober rendering coverage lives in
tests/Unit/Rendering plus the focused Blog and OneUptime tests.
Public layout pilot
Ecommerce owns the declared ecommerceCurrency layout root (active choices and
current session selection). It registers through the existing module provider
contract; core base data remains independent of optional commerce services.
LTS1 consumes this context on home/contact/product and native preview surfaces.
The Blog homepage feed remains page-scoped and is not queried for every layout.
Qualified SEO alternates — core promotion review (2026-10-13)
- Capability/contract: extend the existing
SeoTemplateDatawith a bounded optionalalternatesmap, carried separately from navigation byViewModel::setSeoAlternates(). Core transports safe locale/path values to the single reservedseoroot; it does not determine translation eligibility. Missing/null means unqualified, empty means explicitly none, and neither permits inference fromlocale_switcher. - Consumer A: Ecommerce published product detail owns active catalog identity, exact field-wise translations, ignored default-locale translation rows, slug ownership and current canonical eligibility. Preview is excluded.
- Consumer B: Blog published article detail owns its stable post family, exact translations and whole-row overlays including the default locale. Both consumers publish only current public same-tenant identities, exclude fallback-only/ambiguous content and recompute eligible reciprocal groups on each read. No personal state, write, retention or concurrency mutation crosses the shared presentation boundary; withdrawal removes the affected candidate.
- Ownership: core owns typed validation, one HTML contribution and error- status suppression. Modules retain content/locale/publication and slug policy; their existing Page dependency owns canonical/native route, rewrite and Page collision validation. Core never queries optional module tables. Language navigation remains independent and may offer fallback content.
- Optionality: no new DI key, registry or optional module probe is introduced. Unqualified pages and inactive consumers have no SEO alternative contribution; an explicitly empty map remains valid. Page has no translation-family identity and cannot infer equivalent pages from matching independent slugs.
- Verification: bounded typed/unsafe-path and duplicate-root tests, Kernel transport/navigation tests, both owner SQL/controller tests, all-eight-head renders and module-neutral/DI ownership ratchets. Category and catalog page parity need additional actual listing-source qualification, not this transport. The focused value/transport/head/owner/DI selection passes 403 cases / 3,706 assertions; three actual temporary-Kernel native/source product/Blog cases pass 164 assertions. Scoped production PHPStan and independent review pass. A broader architecture run reports two preexisting untouched failures in TenantLocaleBackfill's Documentation imports and the case-sensitive ArcadeDb migration path scan. Neither is waived or treated as new transport debt.
- Migration: additive metadata only; no schema/settings/data conversion. Forward deploy core and all public heads with consumer qualification together. Unknown or invalid qualification emits no alternates. Rollback must retain that fail-closed boundary rather than restore navigation-derived SEO claims. Export/reimport retain existing owner translation identities.
- Alternatives: local duplicate page roots fragment eight heads; a private library still requires a stable rendering root; events add no value to read- only presentation. Extend the existing core SEO value, keeping domain queries and Page's canonical resolver outside core instead of promoting a SEO service.
- Decision: accept the bounded transport extension based on these two actual consumers (agent source review within the authorized SEO work). Reopen for new transport semantics; additional domain consumers must prove their own family, translation and public-identity rules. Pagination is not declared complete by this record.