How Lattice turns PHP into React: the server-driven contract
Part 2 of the Building Lattice series. Part 1 covered the why.
Last time I made the pitch: describe your UI in PHP, render it as real React over Inertia, decoupled from the data model. This post is about the machinery that makes that work — the contract between the two sides.
The pipeline
Everything flows through one path:
PHP Page → PageSchema (tree of Components) → typed JSON → Inertia → lattice/page → registry → React
Your page’s render() builds a tree of component builders. Lattice serializes that
tree to a typed payload and ships it over Inertia as a normal page visit. On the
client, a single React component — lattice/page — walks the tree and renders it.
That’s the whole idea: the server decides what the screen is, the client only
decides how to draw it.
A node is just a type and some props
When a component serializes, it becomes a plain node: a type, a bag of props,
and optionally a schema array of children. The dashboard from part 1 comes across
the wire looking like this (trimmed):
{
"type": "card",
"props": { "title": "Team settings" },
"schema": [
{ "type": "heading", "props": { "level": 2, "text": "Members" } },
{ "type": "text", "props": { "text": "Three people have access.", "color": "muted" } },
{ "type": "badge", "props": { "label": "3 active" } }
]
}
No behaviour, no model references — just a description. That’s what makes the client
ignorant of your domain in the best way: it has never heard of your Eloquent models,
it just knows how to render a card with a heading inside it.
The registry
On the client, each node’s type is looked up in a registry that maps it to a
React component, recursing through the children. card → your Card component,
heading → your Heading, and so on. The registry is open: when you want a custom
component, you write the React, register it under a type, and emit that type from a
PHP builder. Lattice renders your component through the exact same path as its own.
The same mechanism powers the interactive bits. Submitting a form, paging a table, or clicking an action calls a dedicated Lattice endpoint that runs your PHP and returns the next payload — the client applies it the same way it rendered the first one.
Discovery, not wiring
You almost never register definitions by hand. Annotate a class with #[Page],
#[Form], #[Table], #[Action], or #[Fragment], and Lattice discovers it under
your app/ directory and gives it a stable endpoint (lattice/forms/{form},
lattice/tables/{table}, …). The attribute is the whole registration step.
The decision I’m happiest with
Because the server produces the wire format and the client only consumes it, the two can’t disagree about the shape of a page — as long as they share the contract. So Lattice generates TypeScript types from the PHP enums and value objects that make up that wire format. The React side is typed against the same contract the server serializes, which means drift between PHP and TypeScript is a compile error, not a runtime surprise.
That property is the quiet payoff of the whole approach. There’s no hand-written API to keep in sync, and no second definition of “what a form is” living in the frontend — there’s one model, and the renderer always receives exactly what the server made.
Next up: forms — and why a Lattice form is the fields you choose, not the columns you inherit from a model.