Recipes
The aggregate primitives compose. A handful of common shapes:
Status breakdown — one column per state
For a workflow column with a small enum of values, declare one filtered
SUM and one filtered COUNT per state. Cheap delta path on every save;
one extra UPDATE per state per mutation.
#[NestedSetAggregate(column: 'open_tickets', sum: 'tickets', filter: ['status' => 'open'])]
#[NestedSetAggregate(column: 'open_count', count: true, filter: ['status' => 'open'])]
#[NestedSetAggregate(column: 'closed_tickets', sum: 'tickets', filter: ['status' => 'closed'])]
#[NestedSetAggregate(column: 'closed_count', count: true, filter: ['status' => 'closed'])]
class Project extends Model implements HasNestedSet { use NodeTrait; }
When a ticket flips from open to closed, the package fires a delta
on open_* and on closed_* in the same saved event —
+/- ticket_value on each pair, propagated to every ancestor in one
UPDATE.
Inclusive vs exclusive — totals including and below
Two declarations against the same source column gives you both "my subtree total" (inclusive) and "everything below me" (exclusive) without double-counting. UI screens use the exclusive value when they show self and descendants total side by side; the inclusive one when they show a single rollup.
#[NestedSetAggregate(column: 'budget_inclusive', sum: 'budget')]
#[NestedSetAggregate(column: 'budget_below', sum: 'budget', exclusive: true)]
class Department extends Model implements HasNestedSet { use NodeTrait; }
exclusive: true excludes self from the rollup. A leaf reports
budget_below = 0. A folder with three children each holding
budget = 100 reports budget_inclusive = 300 + own_budget and
budget_below = 300.
Exclusive aggregates are maintained incrementally across every lifecycle hook (create, update, delete, restore, move). The chain-recompute path runs whenever a watched column dirties on save — cost shape is O(depth × subtree-size) per mutation, the same as the MIN/MAX extremum-lost branch. Mutations that don't touch a watched column skip the recompute entirely.
Date-window roll-ups via raw filter
When the filter needs a SQL function or a comparison against something
the equality / not-null forms can't express, drop down to filterRaw.
Watched columns trigger an ancestor-chain recompute on save.
#[NestedSetAggregate(
column: 'recent_revenue',
sum: 'revenue',
filterRaw: 'closed_at >= CURRENT_DATE - INTERVAL 30 DAY',
filterRawWatches: ['closed_at'],
)]
class Account extends Model implements HasNestedSet { use NodeTrait; }
The watch on closed_at says "if this column changes on a save, the
raw-filter column may need to be recomputed for the ancestor chain". A
name change won't trigger the recompute; a closed_at change will.
Date-window filters have a second source of drift: the window slides
every day. Schedule a periodic fixAggregates() (or
queueFixAggregates()) to catch the rows that would re-enter or
leave the window simply because of time passing — none of which fire a
saved event.
Weighted contributions via listener
When each row's contribution is a PHP expression — a product, ratio,
lookup-driven value, anything that isn't a single column reference —
declare a TreeAggregateListener and route through a listener
aggregate. SUM is the common one but COUNT / MIN / MAX work too.
final class RiskWeightedExposureListener implements TreeAggregateListener
{
public function contribution(Model $node): int|float|null
{
return $node->exposure * ($node->risk_score / 100.0);
}
public function watchColumns(): array
{
return ['exposure', 'risk_score'];
}
}
#[NestedSetAggregateListener(
column: 'weighted_exposure',
listener: RiskWeightedExposureListener::class,
operation: AggregateFunction::Sum,
)]
class Position extends Model implements HasNestedSet { use NodeTrait; }
The maintained column is a decimal (declare it via the migration's
standard Blueprint helpers, not nestedSetAggregate, when you need a
non-integer column type). Cast as float or decimal:N on the model.
Conditional contribution via listener null
Listener contribution() can return null to exclude a row. This is
the listener-side equivalent of a filter — useful when the inclusion
test isn't expressible as a SQL predicate.
final class ApprovedAmountListener implements TreeAggregateListener
{
public function contribution(Model $node): ?int
{
// Only "approved" amounts roll up; everything else is excluded.
return $node->status === 'approved' ? (int) $node->amount : null;
}
public function watchColumns(): array
{
return ['status', 'amount'];
}
}
For SUM operations, null is treated as zero (the row doesn't
contribute). For MIN/MAX, null skips the row entirely — useful when
you want "minimum across only the qualifying rows".
Multiple Min/Max sliced by type
Filtered MIN/MAX gives you per-category extrema without a GROUP BY
at read time. Useful for sidebar / dashboard widgets that show "lowest
open priority", "highest urgent priority", etc.
#[NestedSetAggregate(column: 'low_priority_min', min: 'priority', filter: ['status' => 'open'])]
#[NestedSetAggregate(column: 'high_priority_max', max: 'priority', filter: ['status' => 'urgent'])]
class Issue extends Model implements HasNestedSet { use NodeTrait; }
Each column gets its own cheap-delta / recompute behaviour
independently — low_priority_min only triggers a recompute when the
deleted/changed row's value matched the stored extremum AND the row's
status was open.
Ad-hoc fresh aggregates without declaration
withFreshAggregates() accepts inline Aggregate objects — no column
needed on the model, no migration. Useful for one-off reports against
an arbitrary predicate.
$rows = Project::query()
->whereDescendantOf($rootBounds)
->withFreshAggregates([
'p1_count' => Aggregate::count()->filter(['priority' => 1]),
'recent_sum' => Aggregate::sum('amount')
->filterRaw('created_at >= CURRENT_DATE - INTERVAL 30 DAY', []),
])
->get();
filterRaw() inlines its SQL verbatim — there is no parameter binding,
so ? placeholders are emitted literally. Pass only trusted constants
written into the predicate; never user input.
The second argument to filterRaw() is the $watches list — required
on the fluent factory (no default). For declared aggregates the
registry's boot-time validation enforces that this list is populated
or that you opt out via filterRawNoColumnDependencies: true; ad-hoc
aggregates skip that validation since they have no boot pass, but the
signature still requires the array. Pass [] for read-only / one-shot
queries where maintenance doesn't apply.
The returned models have p1_count and recent_sum as computed
attributes. Nothing's persisted; subsequent reads pay the
correlated-subquery cost each time.
Choosing the right form
| Need | Use |
|---|---|
| Sum/count/min/max/avg over a column, all rows | unfiltered #[NestedSetAggregate] |
| Same, but only rows matching column = value | filter: ['col' => v] |
| Same, but only rows where a column is not null | filterNotNull: 'col' |
| Same, but predicate needs SQL functions or comparisons | filterRaw: '...' + filterRawWatches: [...] |
| Contribution is a PHP expression | TreeAggregateListener + #[NestedSetAggregateListener] |
| Aggregate descendants only (not self) | exclusive: true |
| One-off / ad-hoc / no column on the model | withFreshAggregates(['alias' => Aggregate::...]) |