June 18, 2026

How Lattice turns PHP into React: the server-driven contract

By Manuel Christlieb — Staff Engineer

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.