Platform
← Components

COMPONENTS FEATURE

Contract-driven grid

A grid that calls OPTIONS on its feed, reads the route contract and builds its columns from it. The template holds an endpoint, never a column config.

Live demo
grid-runtime-v2.js OPTIONS contract Synthetic feed Self-describing route Graceful degradation

Contract-driven grid

The grid shell in the template is a single element with one piece of data: a pointer to its feed endpoint. There is no server-projected column configuration in the template at all — no column list, no headers, no sort config. The grid learns its own shape from the route it reads.

This is the Presentation Boundary pillar applied to a table: the template carries no data-shaping logic, because the data's shape is declared on the route and discovered, never hand-wired.

The whole template

The live preview on this page is exactly this:

twig
{% include '@platform-ui/components/runtime/grid-v2.html.twig' with {    gridId: 'ui-inventory',    endpoint: '/_feed/inventory',    emptyMessage: 'The inventory is empty.'} only %}

That is the entire integration. The grid shell renders with a data-ui-grid-endpoint pointer and nothing else.

How it works

grid-runtime-v2.js issues an OPTIONS request to the endpoint and reads the route's self-describing contract: the output shape (columns and their types), and the route's declared sort, filter, and pager capabilities. It then builds the entire grid — headers, cells, and any controls — from that contract. The runtime never assumes a shape; it asks the route what it serves.

The same OPTIONS contract is the one description every consumer reads. This grid reads it; an API client reads it; a generated SDK reads it. They cannot drift apart, because there is no second source of truth for the data's shape.

A finite, self-degrading feed

The showcase feed declares a finite synthetic dataset (mode: single). The runtime reads that from the contract and does the sensible thing: it renders a clean static table and no pager, because the contract says there is nothing to page through. That is graceful degradation by contract — the UI adapts to what the route actually offers rather than rendering controls that lead nowhere.

For honesty: this showcase grid is an intentionally finite feed with no pager and no sort/filter UI. The OPTIONS contract is the real, working mechanism by which a grid discovers its shape; richer behaviour — interactive column sorting, server-side filtering UI, paged feeds — is part of the contract vocabulary but roadmap from this showcase's standpoint. What you see here is the discovery mechanism itself, demonstrated on a deliberately small dataset so the contract, not the data volume, is the point.

When to use it

Reach for a contract-driven grid whenever the table's shape is owned by a route rather than a screen — admin tables, operational feeds, anything backed by a real query whose columns you would otherwise hand-copy into a template (and then have to keep in sync). You describe the shape once, on the route; every surface that renders it stays correct for free.

How it ties to the philosophy

A hand-wired column config in a template is a presentation-boundary leak: the template now encodes knowledge about the data that belongs on the route. The contract-driven grid closes that leak structurally. The grid is described once, on the route; the template carries only a pointer; and the rendered UI can never disagree with the data it renders — because both read the same OPTIONS contract.

© Edsger W. Dijkstra:"Simplicity is prerequisite for reliability."

TemplateJust a pointer
// Template — just a pointer:{% include '@platform-ui/components/runtime/grid-v2.html.twig' with {    gridId: 'ui-inventory', endpoint: '/_feed/inventory'} only %}

How it works

grid-runtime-v2.js issues OPTIONS on the endpoint and reads the route's self-describing contract — output fields → columns, plus the collection block (sort fields, filterable facets, search param, pagination policy + per-page options). It then sends view changes back as plain query params (?q=…&sort=-name&filter=status:eq:Beta&page=2&perPage=25); the feed validates them against the SAME declared allowlist and returns the windowed page plus meta.pagination and meta.filterOptions. The whole contract is DECLARED as attributes on the response class (#[CollectionPaginated] / #[CollectionSortable] / #[CollectionFilterable] / #[CollectionSearchable] / #[CollectionFilterOptions]) — change the declaration, the grid changes, the template never moves.

Why it matters

A hand-wired column config — or a bespoke filter form, or pager glue — in a template is a presentation-boundary leak: it encodes knowledge about the data that belongs on the route, and it drifts. The contract-driven grid closes that leak structurally: described once on the route, every consumer (this grid, an API client, a generated SDK) reads the one contract and cannot disagree with the data. Pagination, filtering, sorting and search stop being per-screen plumbing and become route vocabulary.