Skip to content

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.

EnvelopeUseRequired keysOptional keys
PageContractA full page render.version, shell, page, layoutresources, notifications, entities
FragmentA partial slice when the client already owns the page.version, kindpayload, 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:

LayerKeyCarries
ShellshellThe macro frame of the experience, as a string. PageBuilder defaults to product.
Pagepage (PageMeta)Screen identity: key, title, subtitle, breadcrumbs and global actions.
Layoutlayout.templateThe structural template, as a string. PageBuilder defaults to stack; CrudBuilder uses stack, and split for show.
Regionslayout.regionsNamed slots, each an ordered list of blocks. CrudBuilder uses content, and aside for show.
BlocksBlockDescriptorA 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:

KeyValue objectCarries
columnsColumnkey, label, sortable, searchable, a format (ValueFormat), optional formatOptions, options and a cell variant (ColumnVariant, see optional capabilities).
filtersFilterDefinitionkey, label, a control type (FilterType: select, text, date, date_range, boolean, number_range), and optional options, placeholder and default.
rowActionsActionPer-row actions; their targets use an {id} placeholder for the row.
bulkActionsActionActions applied to the selected rows.
optionsTableDisplayOptionsperPage (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:

KindCarries
linkhref, optionally external
routea host-named route and its params, resolved by the client
requestan HTTP mutation: endpoint and method
panelnothing; 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, NavigationNode and CrudBuilder accept a capability string. It is an opaque token that the host adapter maps to its own permission check. PageResources and ResourcePatch carry a capability map of booleans the server has already evaluated.
  • Translation. Translatable serializes a key, a host i18n domain and optional params. 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/:

FileRoot$id
page-contract.jsonPageContracthttps://ui-docs.middag.io/schemas/page-contract.json
fragment.jsonFragmenthttps://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 ​

CheckWhat it guards
composer emit:schemasWrites 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.phpEvery registered type exposes a schema, so a new wire type cannot ship without one.
tests/Schema/SchemaRoundtripTest.phpSerializer output validates against the schema, including required keys and discriminator strictness.
tests/Envelope/ContractEnvelopeInterfaceTest.phpPage 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.

MIDDAG © 2015-2026