June 24, 2026

Tables in Lattice: columns in PHP, rows from anywhere

By Manuel Christlieb — Staff Engineer

Part 4 of the Building Lattice series. Part 3 covered forms.

Tables are the other half of most admin screens, and they’re where the “unopinionated” half of the thesis shows up. You declare columns in PHP; Lattice renders the React table and handles sorting, filtering, and pagination, fetching rows from the table’s own endpoint. The interesting decision is where those rows come from.

Declaring a table

Extend TableDefinition — or EloquentTableDefinition for the common database case — give it an id with #[Table], and declare the columns:

#[AsTable('app.products')]
class ProductsTable extends EloquentTableDefinition
{
    public function columns(): array
    {
        return [
            TextColumn::make('name')->sortable()->filterable(),
            TextColumn::make('price')->numeric()->sortable(),
            TextColumn::make('featured')->boolean(),
            TextColumn::make('updated_at')->date('Y-m-d')->sortable(),
        ];
    }

    public function builder(TableQuery $query): Builder
    {
        return Product::query();
    }
}

EloquentTableDefinition does the obvious thing: you hand it a query builder and it runs the sorting, filtering, and pagination against it. There are hooks for the rest — perPage(), pagination(), striped(), emptyLabel(), per-row actions($row), and bulkActions() for selections.

Rows from anywhere

Here’s the part I care about. A table’s rows come from a TableSource, and that’s an interface, not a base class welded to Eloquent:

interface TableSource
{
    public function query(TableQuery $query): TableResult;
    public function resolveMatching(TableQuery $query): Collection;
    public function resolveSelection(array $keys): Collection;
}

TableQuery is the read model of the current request — its filters, sorts, page, and page size. TableResult wraps the rows plus pagination metadata. The Eloquent source ships in the box, but nothing stops you backing a table with an array, a search index like Meilisearch, or a third-party API. Implement three methods and the same React table renders it, with the same sorting and filtering UI on top.

That’s the unopinionated bet again: Lattice has an opinion about what a table is (columns, a page of rows, optional actions) but not about where the data lives. The common case is easy; the uncommon case is possible without leaving the framework.

Same pattern, all the way down

Step back and pages, forms, and tables are the same shape: a PHP definition, discovered by an attribute, serialized to a typed tree, rendered by React, talking back through a dedicated endpoint. Learn it once for pages and forms and tables come for free — and none of them require coupling your UI to your database schema.

That wraps the core tour. Next time I’ll get into actions and effects — how a click runs PHP on the server and dispatches a toast, a redirect, or a refresh back to the client — which is where the server-driven model starts to feel genuinely interactive.