Relationship Columns
A table column can read a related record instead of a column of its own table: how many posts an author has, whether an order has any refunds, the name on a linked profile. Reach for this when the value belongs to another table but the row it describes is this one.
Three separate things live here, kept apart because the query needs different work from each: aggregates are computed in the select, sorting by a related column uses a correlated subquery, and searching a relation is a whereHas. They are all on the base Column, so every column type has them.
A minimal working example
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Authors\Tables;
use PandaPanel\Tables\Columns\NumberColumn;
use PandaPanel\Tables\Columns\TextColumn;
use PandaPanel\Tables\TableSchema;
final class AuthorsTable
{
public static function configure(TableSchema $table): TableSchema
{
return $table->columns([
TextColumn::make('name')->searchable()->sortable(),
// Computed in the select: one query for the whole page.
NumberColumn::make('posts_count')->counts('posts')->sortable(),
]);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Aggregates
use PandaPanel\Tables\Columns\Column;
Column::counts(string $relation): static
Column::exists(string $relation): static
Column::sum(string $relation, string $column): static
Column::avg(string $relation, string $column): static
Column::min(string $relation, string $column): static
Column::max(string $relation, string $column): static2
3
4
5
6
7
8
use PandaPanel\Tables\Columns\BooleanColumn;
use PandaPanel\Tables\Columns\NumberColumn;
NumberColumn::make('posts_count')->counts('posts'),
BooleanColumn::make('refunds_exists')->exists('refunds'),
NumberColumn::make('orders_sum_total')->sum('orders', 'total')->prefix('$')->decimals(2),
NumberColumn::make('orders_avg_total')->avg('orders', 'total')->decimals(2),
NumberColumn::make('orders_min_total')->min('orders', 'total'),
NumberColumn::make('orders_max_total')->max('orders', 'total'),2
3
4
5
6
7
8
9
TableSchema::applyColumnQueries() runs before the paginator and lets every column shape the query, so the aggregate is asked for once for the whole page whatever the row count. Reading it per record would be a query per record — the thing eager loading exists to prevent.
Each case maps to one Eloquent call and to the attribute Eloquent generates for it:
| Method | PandaPanel\Tables\Enums\RelationshipAggregate | Eloquent call | Generated attribute |
|---|---|---|---|
counts('posts') | Count | withCount('posts') | posts_count |
exists('posts') | Exists | withExists('posts') | posts_exists |
sum('orders', 'total') | Sum | withAggregate('orders', 'total', 'sum') | orders_sum_total |
avg('orders', 'total') | Avg | withAggregate(..., 'avg') | orders_avg_total |
min('orders', 'total') | Min | withAggregate(..., 'min') | orders_min_total |
max('orders', 'total') | Max | withAggregate(..., 'max') | orders_max_total |
A dotted relation path has its dots replaced with underscores in the attribute name, exactly as Eloquent does it: counts('posts.comments') lands on posts_comments_count.
The cell reads that generated attribute, not the column's own name:
use PandaPanel\Tables\Columns\Column;
Column::aggregateAttribute(): ?string // 'posts_count', or null for a plain column
Column::getAggregateRelation(): ?string // 'posts'
Column::applyQuery(Builder $query): void // called by TableSchema::applyColumnQueries()
Column::summaryUsesAggregate(): bool2
3
4
5
6
use PandaPanel\Tables\Columns\NumberColumn;
// The column may be called anything; it still reads `posts_count`.
NumberColumn::make('published')->label('Posts')->counts('posts')->aggregateAttribute(); // 'posts_count'2
3
4
Sorting an aggregate
The generated attribute is a real column of the result set, so ordering by it is an ordinary ORDER BY and needs no special strategy. Calling an aggregate method sets the sort column to that attribute for you:
use PandaPanel\Tables\Columns\NumberColumn;
// Name matches the generated attribute — the common case, and the safe one.
NumberColumn::make('posts_count')->counts('posts')->sortable(),2
3
4
sortable(bool $sortable = true, ?string $column = null) assigns $column unconditionally, so calling it after an aggregate with no argument clears the sort column back to the column's own name. When the two differ, say so:
use PandaPanel\Tables\Columns\NumberColumn;
NumberColumn::make('published')
->label('Posts')
->counts('posts')
->sortable(column: 'posts_count'),2
3
4
5
6
Summarizing an aggregate
use PandaPanel\Tables\Columns\NumberColumn;
use PandaPanel\Tables\Summaries\Average;
use PandaPanel\Tables\Summaries\Sum;
NumberColumn::make('posts_count')
->counts('posts')
->summarize([Sum::make()->label('Total'), Average::make()]),2
3
4
5
6
7
Column::summaryColumn() returns the aggregate attribute when there is one, so the figure sums what the query actually selected. Because posts_count exists only in the SELECT list, the summary is computed outside a subquery that produced it rather than as sum(posts_count) against the table, which would be an unknown column. See Summaries.
Sorting by a related column
use PandaPanel\Tables\Columns\Column;
Column::sortableByRelation(string $relation, string $column): static
Column::getSortRelation(): ?string
Column::applyRelationshipSort(Builder $query, SortDirection $direction): void2
3
4
5
use PandaPanel\Tables\Columns\TextColumn;
TextColumn::make('author_name')
->label('Author')
->sortableByRelation('author', 'name'),2
3
4
5
This makes the column sortable and takes over the ordering. The generated order is a correlated subquery:
order by (select name from profiles where profiles.author_id = authors.id limit 1) ascNever a join: a join against a to-many relation multiplies rows and quietly breaks both the page size and the total. A test asserts exactly that — sorting two projects by their hasOne brief still reports a total of two.
The subquery compares the relation's qualified foreign key with the qualified parent key, which is the shape of a HasOne, HasMany, and their morph variants — the foreign key lives on the related table. A BelongsTo keeps the foreign key on this table, so the comparison would be between two columns of the same table; order those with sortUsing() instead.
Searching a relation
use PandaPanel\Tables\Columns\TextColumn;
TextColumn::make('author.name')->label('Author')->searchable(),2
3
A searchable name containing a dot is routed to whereHas rather than to a LIKE: author.name is not a column of this table, and matching it as one is a SQL error rather than an empty result. The last dot separates the relation path from the column, so author.profile.city searches the author.profile relation for city.
TableSchema keeps the two kinds apart:
use PandaPanel\Tables\TableSchema;
TableSchema::getSearchColumns(): array // local names only
TableSchema::getSearchRelations(): array // the dotted ones
TableSchema::isSearchable(): bool // true when either is non-empty2
3
4
5
use PandaPanel\Tables\Columns\TextColumn;
use PandaPanel\Tables\TableSchema;
$schema = TableSchema::make()->columns([
TextColumn::make('name')->searchable(),
TextColumn::make('tasks.name')->searchable(),
]);
$schema->getSearchColumns(); // ['name']
$schema->getSearchRelations(); // ['tasks.name']2
3
4
5
6
7
8
9
10
A column can also point at several places at once, mixing local and related:
use PandaPanel\Tables\Columns\TextColumn;
TextColumn::make('name')->searchable(columns: ['first_name', 'last_name', 'company.name']),2
3
Each word of the term is matched inside its own where(...) group, so a relation search never widens a filter that was already applied. See Search.
Reading a related value in a cell
Column::resolveValue() reads the attribute with data_get(), so dot notation works for display as well as for search:
use Illuminate\Support\Collection;
use PandaPanel\Tables\Columns\TextColumn;
TextColumn::make('author.name')->label('Author')->placeholder('Unassigned'),
// A to-many path gives a collection of values, so format it into a string.
TextColumn::make('tags.name')
->label('Tags')
->placeholder('None')
->formatUsing(static fn (mixed $value): ?string => $value instanceof Collection && $value->isNotEmpty()
? $value->implode(', ')
: null),2
3
4
5
6
7
8
9
10
11
12
Reading a relation this way loads it, which is a query per row unless the resource eager-loads it. That is what Resource::$with is for — it is applied to every query the resource builds, so serializing a column can never trigger a lazy load per row:
/**
* @var list<string>
*/
protected static array $with = ['author', 'tags'];2
3
4
An aggregate needs no with() — that is the point of computing it in the select.
Notes
- Three tools, three jobs. An aggregate answers "how many";
sortableByRelation()answers "in whose order"; a dottedsearchable()answers "does the related record match". Using one for another's job is where the surprising queries come from. - The aggregate attribute is derived with Eloquent's own rule, so the column reads exactly what the query wrote. Name the column after it and everything — cell, sort, summary — lines up without a second declaration.
->sortable()after an aggregate resets the sort column. Pass the attribute explicitly, or callsortable()before the aggregate method.exists()is cheaper thancounts()when the number is not what is being shown; the cell is a boolean.- A dotted searchable name is not a sortable one.
Column::getSortColumn()leaves a dotted name alone rather than sorting by a column that does not exist; usesortableByRelation(). - Filters and the query builder do not traverse relations. A
QueryBuilderFilterconstraint names a column of the queried table. Narrow a relation with aFormFilterand awhereHasclosure. See Query builder. - Relation managers are a different feature. A relationship column shows related data inside a resource table; a relation manager gives a record's related records a table of their own. See Relation tables.