Footers
A table that lists amounts invites the question "what does that come to". A footer answers it, one aggregate per column.
A footer is built from rows you declare. Each row renders one aggregate at one scope, across the columns that opted into that aggregate.
<?php
// app/Tables/InvoiceTable.php
declare(strict_types=1);
namespace App\Tables;
use BrickNPC\EloquentTables\Table;
use BrickNPC\EloquentTables\Column;
use BrickNPC\EloquentTables\Aggregates\Sum;
use BrickNPC\EloquentTables\Enums\AggregateScope;
use BrickNPC\EloquentTables\ValueObjects\FooterRow;
class InvoiceTable extends Table
{
public function columns(): array
{
return [
new Column('number'),
new Column('total')->currency()->aggregate(new Sum()),
];
}
public function footer(): array
{
return [
new FooterRow(new Sum(), AggregateScope::Page, __('This page')),
new FooterRow(new Sum(), AggregateScope::Total, __('All invoices')),
];
}
}
That renders two footer rows: the total of the invoices on screen, and the total of every invoice matching the current search and filters.
A table that declares no footer rows renders no footer at all.
A column opts in
A footer row only fills a cell where the column declared that aggregate. Nothing is aggregated by accident, so an id, a year or a postcode stays empty unless you ask for it.
new Column('total')->aggregate(new Sum(), new Average());
Calling aggregate() again adds to what is already there rather than replacing it, the same way style() does.
A footer row naming an aggregate that no column offers renders as a row of empty cells.
The two scopes
AggregateScope::Page aggregates the rows on screen. AggregateScope::Total aggregates everything matching the current
search and filters, ignoring the page limit.
On a paginated table these are different numbers, and which one a reader wants depends on what they are doing. Declare both if both are useful.
A total-scope row costs an extra query per column it fills. Three columns aggregated across the whole set is three more queries per page load. A page-scoped row costs nothing extra, because the rows are already loaded.
Available aggregates
| Aggregate | Current page | Whole result set | Renders in the column's unit |
|---|---|---|---|
Sum | Yes | Yes | Yes |
Average | Yes | Yes | Yes |
Median | Yes | No | Yes |
Count | Yes | Yes | No |
Min | Yes | Yes | Yes |
Max | Yes | Yes | Yes |
They live in BrickNPC\EloquentTables\Aggregates.
Median has no whole-result-set answer because there is no median function that works across the databases Laravel
supports. Computing one would mean loading every matching row into memory, which defeats the point of paginating, so a
total-scope median renders as an empty cell instead.
An empty result set
Each aggregate answers for itself rather than the footer guessing. Sum and Count return zero, because the sum and
count of nothing genuinely are zero. Average, Median, Min and Max render empty, because an empty set has no
average and no smallest value.
Columns with no grand total
A column whose value comes from a closure has no database column behind it:
new Column('line_total', valueUsing: fn (Invoice $invoice) => $invoice->quantity * $invoice->price)
->aggregate(new Sum());
A page-scoped row works, because the values are already computed. A total-scope row renders empty, because there is nothing in the database to aggregate.
Formatting
An aggregate that returns the column's unit renders through the column's formatter, so the sum of a currency column
renders as currency. A Count does not, because a count of a money column is a number of rows rather than an amount.
One exception. If a column's formatter takes a closure parameter, that closure resolves against a row, and a footer value has no row:
new Column('total')->currency(currency: fn (Invoice $invoice) => $invoice->currency);
The footer renders that value unformatted rather than refusing to aggregate it.
The label
A label is optional. A single sum or count under a column usually speaks for itself, and a row without one renders an empty cell in its place:
new FooterRow(new Sum(), AggregateScope::Page);
Label a row when the footer holds more than one, because two anonymous numbers stacked on top of each other tell a reader nothing about which is which.
By default a row's label sits in a cell spanning the columns to the left of the aggregated ones. Every row spans the same width, decided by the leftmost column any row in the footer aggregates, so stacked figures stay comparable.
Labels belong to rows rather than to columns. Two aggregated columns side by side each get their own value cell, with a single label to the left of both:
| Name | Amount | Quantity |
|------------|--------|----------|
| ... | 10,00 | 2 |
| ... | 20,00 | 3 |
| This page | 30,00 | 5 | <- one label, two values
A spanning label needs a column to its left. If the leftmost column of the table is itself aggregated there is no cell
for the label, so none is rendered. Either accept that, put a non-aggregated column first, or name a column with
labelColumn and give up that column's value.
To put a label somewhere else, name the column it should sit in:
new FooterRow(new Sum(), AggregateScope::Total, __('All invoices'), labelColumn: 'number');
A label may also be a closure, which is resolved when the footer renders:
new FooterRow(new Sum(), AggregateScope::Page, fn () => __('This page'));
Styling a footer row
A footer row takes RowStyle cases, the same vocabulary row styling uses:
use BrickNPC\EloquentTables\Enums\RowStyle;
new FooterRow(new Sum(), AggregateScope::Total, __('All invoices'), styles: [RowStyle::Primary]);
There is no closure form here, because a footer row has no model to vary on.
Writing your own aggregate
An aggregate is a class satisfying BrickNPC\EloquentTables\Contracts\Aggregate. Nothing about the built-in ones is
special, so your own sits alongside them.
<?php
// app/Tables/Aggregates/Range.php
declare(strict_types=1);
namespace App\Tables\Aggregates;
use Illuminate\Support\Collection;
use BrickNPC\EloquentTables\Contracts\Aggregate;
use Illuminate\Contracts\Database\Query\Builder;
final readonly class Range implements Aggregate
{
public function __construct(private int $precision = 0) {}
public function forPage(Collection $values): float|int|null
{
return $values->isEmpty()
? null
: round($values->max() - $values->min(), $this->precision);
}
public function forQuery(Builder $query, string $column): float|int|null
{
$max = $query->max($column);
$min = $query->min($column);
return $max === null || $min === null ? null : round($max - $min, $this->precision);
}
public function carriesColumnUnit(): bool
{
return true;
}
}
Three things to know:
- Return null for a scope you cannot answer. That cell renders empty. This is how
Mediandeclines the whole result set, and it is how you decline a scope that would be too expensive or has no SQL form. - The return type is a value for presentation, not for arithmetic. The contract allows
float|int|string|\Stringable|null, and an implementation may narrow that further, asAveragenarrows to?float. Numbers stay numbers rather than becoming strings, because a unit-carrying result still has to pass through the column's formatter. forQuery()receives the query with search, filters and sorting already applied, and a fresh copy each time, so you can run whatever you need on it without affecting the rows or another aggregate.- The column's instance does the work. A footer row names which aggregate it wants by class, and the instance the column declared computes it. So a column can configure its own aggregate while a row stays generic:
// The column's rounding wins, and the footer row just asks for a Range.
new Column('total')->aggregate(new Range(precision: 2));
new FooterRow(new Range(), AggregateScope::Page, __('Spread'));