Tables in Lattice: columns in PHP, rows from anywhere
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.