UI wire contract
This page describes the JSON that middag-php-ui emits and that a client renderer consumes. UI-002 records why there is one versioned contract published as schema; this page is the specification.
Envelopes and version
Two envelopes go on the wire. Both implement ContractEnvelopeInterface, which holds the single VERSION constant (currently '1'). Each serialized envelope carries it as version, so a full page and a partial update can never disagree on the contract version.
| Envelope | Use | Required keys | Optional keys |
|---|---|---|---|
PageContract | A full page render. | version, shell, page, layout | resources, notifications, entities |
Fragment | A partial slice when the client already owns the page. | version, kind | payload, notifications, resources, customType |
Optional keys are omitted from the JSON when they are empty, so adding an optional capability does not change the wire shape of payloads that do not use it.
Page contract layers
A PageContract is built from five layers:
| Layer | Key | Carries |
|---|---|---|
| Shell | shell | The macro frame of the experience, as a string. PageBuilder defaults to product. |
| Page | page (PageMeta) | Screen identity: key, title, subtitle, breadcrumbs and global actions. |
| Layout | layout.template | The structural template, as a string. PageBuilder defaults to stack; CrudBuilder uses stack, and split for show. |
| Regions | layout.regions | Named slots, each an ordered list of blocks. CrudBuilder uses content, and aside for show. |
| Blocks | BlockDescriptor | A typed widget: type, key, data, with optional variant, title, subtitle, actions, meta, poll, deferred and remember. |
entities maps an entity type to a route pattern; blocks resolve entity references against it. resources (PageResources) carries user preferences, a capability map, feature flags, the current identity and branding.
Open vocabulary
Shell names, layout templates, region names and block types are plain strings in PHP and in the schema. The package does not enumerate them: the client's registry decides which values it can render. BlockBuilder and RegionBuilder ship named constructors for the standard block types (dense_table, form_panel, detail_panel, metric_card, empty_state, status_strip, activity_timeline, markdown_panel, card_grid, action_grid, link_list, chart, tabs). RegionBuilder::block() emits any other type, and the same contract carries standard and custom blocks the same way. Tables and forms are blocks (dense_table, form_panel), not per-screen code; see Tables and forms.
Block hints
deferred: true marks a block whose data the client fetches after the first render. remember: true marks a block whose local UI state the client restores on back and forward navigation. Both are omitted when false. Their docblocks describe them in terms of the first-party client's transport (Inertia deferred props and useRemember); whether they stay renderer-neutral is an open question in UI-002.
Tables and forms
Tables
A dense_table block carries a table definition plus the current page of rows. TableConfig (built by TableBuilder, or by CrudBuilder for an L1/L2 index page) serializes:
| Key | Value object | Carries |
|---|---|---|
columns | Column | key, label, sortable, searchable, a format (ValueFormat), optional formatOptions, options and a cell variant (ColumnVariant, see optional capabilities). |
filters | FilterDefinition | key, label, a control type (FilterType: select, text, date, date_range, boolean, number_range), and optional options, placeholder and default. |
rowActions | Action | Per-row actions; their targets use an {id} placeholder for the row. |
bulkActions | Action | Actions applied to the selected rows. |
options | TableDisplayOptions | perPage (default 25), optional sortColumn, sortDirection (default desc), selectable and searchable. |
CrudBuilder merges this configuration with rows and a Pagination (page, perPage, total, lastPage; Pagination::of() derives lastPage) into the block data. The contract declares which columns sort and search and which filters exist; the server applies sorting, filtering and pagination and sends the resulting page. CrudBuilder turns selectable on when bulk actions are configured and searchable on when any column is searchable. The block meta bag can ask the client to sort, filter and paginate the supplied rows itself (clientSide); meta is passthrough, so the client decides which keys it reads.
TableConfig, TableDisplayOptions, FilterDefinition, Pagination and Column are registered in the schema bundles, but the dense_table block data stays a passthrough object, so the schema does not validate a table payload inside a page.
Forms
A form_panel block carries action, a lowercase method, a schema (a list of form schema nodes), values, errors and meta; BlockBuilder::formPanel() defaults errors to an empty object and meta.validation to both, and adds steps and multiStep for a multi-step form. Each schema entry is either a FormSchemaNodeInterface value object (FormFieldNode, FormGroupNode, FormSectionNode, FormHeaderNode), serialized by the builder, or a loose array that passes through unchanged while producers migrate to the value objects. A file field's value is a FileValue (or a list of them), on the wire either a bare URL or an object with url and optional name, size, type, id and draftitemid.
The form DSL itself lives in the framework. A host renderer that cannot consume the JSON implements FormRendererInterface, which returns a RendererOutput for one RenderTarget; those renderers live in the framework and the host adapters, not in this package.
Fragments and mutation results
FragmentKind tells the client how to route a fragment: block, region, table, form, detail, inspector, notifications or custom. A custom fragment also carries customType, so a host can ship its own payload kind without widening the enum. A notifications fragment has no payload. Fragments reuse the page value objects; they introduce no new data shapes.
A region fragment carries a RegionUpdate with a mode from RegionUpdateMode: replace, append, prepend and update read blocks; remove reads keys.
ActionResult is the server's answer to a mutation. It carries success, notifications, an optional redirect, validation errors keyed by field (the reserved _ key holds form-level errors), and two ways to update the view: refreshBlocks lists block keys the client re-fetches (pull), and fragments carries ready pieces to swap in (push). A ResourcePatch on a fragment or an action result updates preferences, capabilities or feature flags without resending PageResources.
Actions
An Action has an id, a label, an intent and a target. ActionTarget is discriminated by kind:
| Kind | Carries |
|---|---|
link | href, optionally external |
route | a host-named route and its params, resolved by the client |
request | an HTTP mutation: endpoint and method |
panel | nothing; opens the page's editable panel for the acting row |
Opaque data
The contract carries authorization and translation as data and never resolves them:
- Authorization.
Action,NavigationNodeandCrudBuilderaccept acapabilitystring. It is an opaque token that the host adapter maps to its own permission check.PageResourcesandResourcePatchcarry a capability map of booleans the server has already evaluated. - Translation.
Translatableserializes akey, a host i18ndomainand optionalparams. The client resolves it with the host's i18n tool. Entity data stays a plain string; the client tells the two apart by type (object to resolve, string to print).
Navigation is built through NavigationRegistryInterface: modules register groups, sections and items during boot, and the registry filters by capability and serializes the tree for the client. Hosts register against the registry instead of exposing their own navigation callbacks.
Published schemas
The PHP value objects are the source of truth. Each wire value object and enum declares a hand-authored jsonSchema() next to its jsonSerialize(). SchemaRegistry (@internal) bundles them into two JSON Schema draft 2020-12 files under schema/:
| File | Root | $id |
|---|---|---|
page-contract.json | PageContract | https://ui-docs.middag.io/schemas/page-contract.json |
fragment.json | Fragment | https://ui-docs.middag.io/schemas/fragment.json |
Each bundle declares x-contract-version (the envelope VERSION), a $ref to its root and every type under $defs. @middag-io/react generates its TypeScript types and zod schema from these files.
The schemas are strict. Every value object is additionalProperties: false, and discriminated unions such as ActionTarget use oneOf on a kind const. The only open objects are passthrough bags: a block's data and meta, layout meta, a fragment's payload, Translatable and route params, a column's options and formatOptions, and UserPreferences. The form schema tree (FormSchemaNode and its node types) is registered but not yet referenced from the page tree; the form_panel block payload stays a passthrough.
Drift gate
| Check | What it guards |
|---|---|
composer emit:schemas | Writes schema/*.json from the registry. |
composer check:schemas (bin/emit-schemas.php --check, part of composer check) | Fails when a committed schema differs from the code. |
tests/Schema/SchemaCoverageTest.php | Every registered type exposes a schema, so a new wire type cannot ship without one. |
tests/Schema/SchemaRoundtripTest.php | Serializer output validates against the schema, including required keys and discriminator strictness. |
tests/Envelope/ContractEnvelopeInterfaceTest.php | Page and fragment share one version constant. |
What the contract does not cover
The contract does not guarantee that a particular host or client renders every block type, layout or shell; that is the client registry's job. Rendering, HTTP transport and host wiring live in middag-io/framework, the host adapters and the client. See Page composition contract for the builder levels and ui architecture for the package boundaries.
Verified against middag-php-ui/main@8602bb179816ed2c51c633855535a985792abb72 on 2026-11-04.