July 3, 2026

Fragments in Lattice: reusable pieces of a page, loaded lazily

By Manuel Christlieb — Staff Engineer

Part 7 of the Building Lattice series. Part 6 covered layouts and menus.

By now the pattern is familiar: a page is one PHP definition that serializes to a tree and renders in one pass. That’s great until part of the page is expensive — a chart that aggregates a quarter of sales, a panel that calls a slow API. You don’t want that work blocking the first paint of the whole dashboard. Fragments are the answer: a reusable chunk of schema with its own endpoint, loaded on demand.

A fragment is a piece of schema

Extend FragmentDefinition, mark it with #[AsFragment], and return a slice of schema — exactly what a page does, just smaller and named:

#[AsFragment('app.revenue-trend')]
final class RevenueTrendChart extends FragmentDefinition
{
    public function schema(PageSchema $schema): PageSchema
    {
        return $schema->component(
            Chart::make('Revenue')
                ->height(220)
                ->categoryKey('month')
                ->data($this->monthlyRevenue())
                ->line('revenue', 'Revenue', color: '#2563eb'),
        );
    }
}

Notice the signature: schema(PageSchema $schema) — and that’s all. No Request. A layout’s schema() takes the request; a fragment’s deliberately doesn’t.

Why no request? Because it should be reusable

That missing argument is the design decision, not an oversight. A fragment isn’t allowed to depend on the current request — which means the same fragment renders identically wherever you drop it. It can sit on the dashboard, in a modal, and on a detail page, and it’s the same chunk every time. (Anything record-specific you need, you pass in as context when you place it, not by reaching into the request.) That constraint is what makes a fragment genuinely reusable rather than just extracted, and it keeps the output cache-friendly.

Placing one: lazy by default

A page references a fragment by class, and Lattice wires up the lazy load:

Grid::make('dashboard')->columns(3)->schema([
    Fragment::lazy(RevenueTrendChart::class)->size(Size::Lg),
    Fragment::lazy(SalesMixChart::class)->size(Size::Lg),
    Fragment::lazy(OrderVolumeChart::class)->size(Size::Lg),
]);

Fragment::lazy(...) doesn’t put the chart in the page payload. It emits a small placeholder node with a signed endpoint of its own. On the client the fragment renders a skeleton — sized by ->size(...), so the layout doesn’t jump — then fetches its real schema and swaps it in. Three charts on a dashboard become three independent requests that load in parallel, none of them holding up the initial render of the page around them.

What this buys you

Three things, really:

  • Faster first paint. The page ships without the heavy bits; they stream in after. The shell and the cheap content are interactive immediately.
  • Reuse without duplication. The revenue chart is one definition, dropped wherever it’s needed — change it once, every placement updates.
  • Independent refresh. A fragment has its own endpoint, so it can reload on its own (an action can target it with the reloadComponent effect from Part 5) without re-rendering the whole page.

And it’s still the same contract underneath. A fragment is a PageSchema like any other; the only difference is when it crosses the wire. The lazy boundary is an optimization layered on top of the node tree, not a separate rendering path — so everything you already know about components, forms and actions works inside a fragment unchanged.

That rounds out the core tour of Lattice: pages, the contract, forms, tables, actions, layouts and now fragments. Each one turned out to be the same shape wearing a different hat — describe it in PHP, serialize a typed tree, let React render it, talk back through a dedicated endpoint, never couple the UI to the database schema. From here the series moves from “what Lattice is” to the harder questions of building it: the things still half-finished, and the calls I’m not sure about yet.