Skip to content

Computed values

A column does not have to be a column of the table: state() computes the value per record, and queryColumn() tells the list which database columns to use for sorting and searching.

TextColumn::make('name')
->state(fn ($person): string => $person->last_name . ', ' . $person->first_name),
TextColumn::make('hint')
->state(fn ($person): ?string => $person->entry_date > date('Y-m-d') ? 'Joins soon' : null),
TextColumn::make('total')->money('EUR')
->state(fn ($order) => $order->quantity * $order->price),

The closure receives the record (an array or an entity, as the model returns it) and returns the value; the format options of the column apply to it (money(), date(), labels(), …). Return null for an empty cell.

The closure runs for every shown row: keep it cheap. Do not query the database per row – use a join, a sub-select or a CountColumn.

A computed value cannot be sorted or searched by itself: the database does not know it. sortable() and searchable() only take effect when the column names the database columns with queryColumn():

TextColumn::make('name')
->state(fn ($person): string => $person->last_name . ', ' . $person->first_name)
->queryColumn('people.last_name', 'people.first_name')
->sortable()
->searchable(),
  • Sorting uses the columns in order: by last name, then first name.
  • Searching matches each column on its own: müller finds “Müller, Hans”, “Hans Müller” finds both words separately (OR), but not as one value. To search a composed value use a TextFilter with expression().
  • Names are column or alias.column ([a-z_][a-z0-9_]*); anything else throws an InvalidArgumentException. Plain columns of the main table are prefixed with its name automatically when no queryColumn() is set.

queryColumn() also works without state(), e.g. when the attribute name of the record differs from the column to sort by.

Values of other tables come into the list with a join in scopeQuery() of the resource. The tenant scope of the main model stays in place.

public function scopeQuery(Model $model): void
{
$model->select('invoices.*, c.name AS customer_name')
->join('customers c', 'c.id = invoices.customer_id');
}
public function collection(Collection $collection): Collection
{
return $collection->columns([
TextColumn::make('number')->sortable()->searchable(),
TextColumn::make('customer_name')->label('Customer')->queryColumn('c.name')->sortable()->searchable(),
]);
}

A person’s full name from a join, sorted by last name, then first name:

public function scopeQuery(Model $model): void
{
$model->select("contacts.*, CONCAT(p.last_name, ', ', p.first_name) AS person", false)
->join('people p', 'p.id = contacts.person_id');
}
// in collection():
TextColumn::make('person')->queryColumn('p.last_name', 'p.first_name')->sortable()->searchable(),

Joined tables are not scoped by Anvil: join only through keys of records the main query already restricts (as above), or add the tenant condition to the join yourself.