Skip to content

Eloquent tables

Backing a table with an Eloquent model is common enough that Lattice ships a source for it. Extend EloquentTableDefinition and implement builder() instead of source() — Lattice wires up an EloquentTableSource from your builder, columns, and pagination mode. It is the same TableSource contract underneath, with the query-building done for you.

use Illuminate\Database\Eloquent\Builder;
use Lattice\Lattice\Attributes\AsTable;
use Lattice\Lattice\Tables\Columns\NumberColumn;
use Lattice\Lattice\Tables\Columns\TextColumn;
use Lattice\Lattice\Tables\Sources\Eloquent\EloquentTableDefinition;
use Lattice\Lattice\Tables\TableQuery;
/**
* @extends EloquentTableDefinition<Product>
*/
#[AsTable('app.products')]
class ProductsTable extends EloquentTableDefinition
{
public function columns(): array
{
return [
TextColumn::make('name')->sortable()->filterable(),
NumberColumn::make('price')->sortable()->filterable(),
];
}
public function builder(TableQuery $query): Builder
{
return Product::query()->select(['id', 'name', 'price']);
}
}

builder() returns the base query — the rows the table draws from before the request’s filters and sorts are applied. Lattice applies those for you (see below), so the builder only needs your own scoping: a select(), eager loads, tenant constraints, a default order.

public function builder(TableQuery $query): Builder
{
$builder = Product::query()
->where('team_id', $this->team->id)
->select(['id', 'name', 'price', 'status']);
if ($query->sorts === []) {
$builder->latest('id');
}
return $builder;
}

The TableQuery is passed in so the builder can react to the current request — the example applies a default order only when the user hasn’t chosen a sort.

EloquentTableSource reads the request’s filters and sorts from the TableQuery and applies them to your builder automatically, driven by the columns’ capabilities:

  • A filter is applied only for a column that is filterable(), using the column’s FilterType to build the right where.
  • A sort becomes an orderBy on the column key.

Because both map to the column key, a filterable or sortable column key must be a real database column (or an aliased select) — or a relation field, below. For anything computed, keep the column display-only or back the table with a custom source.

A column keyed with dot notation reads through a to-one relation. TextColumn::make('author.name') resolves the author relation and shows its name; Lattice eager-loads the relation, so there is no N+1. Because it maps to a real relation and field, it stays fully sortable and filterable — the sort becomes a correlated subquery and the filter a whereHas:

public function columns(): array
{
return [
TextColumn::make('title')->sortable()->filterable(),
TextColumn::make('author.name')->label('Author')->sortable()->filterable(),
];
}
public function builder(TableQuery $query): Builder
{
return Post::query()->select(['id', 'title', 'author_id']);
}

For a to-many relation, key the column by the relation name and name the field with ->multiple(). It renders the related values as a list, filters through whereHas, and is never sortable. Combine it with ->badge() to render each related value as a pill:

TextColumn::make('tags')->multiple('name')->label('Tags')->filterable();

When a table has bulk actions, the source also resolves the selected rows. EloquentTableSource handles this from your builder: resolveMatching() re-runs the filtered query for “select all matching”, and resolveSelection() loads an explicit set of keys with whereKey(). You don’t write anything extra — defining the bulk actions is enough.