Listener Aggregates
When a contribution requires PHP logic that can't be expressed as a SQL
column reference — for example SUM(base_power * level) where the
product is computed per node — declare a listener aggregate:
use Illuminate\Database\Eloquent\Model;
use Vusys\NestedSet\Aggregates\TreeAggregateListener;
class WeightedPowerListener implements TreeAggregateListener
{
public function contribution(Model $node): int|float|null
{
return (int) $node->base_power * (int) $node->level;
}
/** Columns whose changes should trigger re-aggregation on ancestors. */
public function watchColumns(): array
{
return ['base_power', 'level'];
}
}
Declare it on the model with #[NestedSetAggregateListener]:
use Vusys\NestedSet\Aggregates\AggregateFunction;
use Vusys\NestedSet\Attributes\NestedSetAggregateListener;
#[NestedSetAggregateListener(column: 'weighted_power', listener: WeightedPowerListener::class, operation: AggregateFunction::Sum)]
#[NestedSetAggregateListener(column: 'fire_count', listener: FireCountListener::class, operation: AggregateFunction::Sum)]
class Monster extends Model implements HasNestedSet { use NodeTrait; }
contribution() returns this node's value. null means "exclude this
node" — useful for Min/Max where some nodes have no meaningful value.
watchColumns() declares which attribute changes trigger incremental
maintenance.
Supported operations: Sum, Count, Min, Max, Avg. Avg is
auto-promoted into a pair of internal Sum + Count companions plus
the display column — see Listener AVG below.
Migration
Listener columns use the same macro as SQL aggregates:
$table->nestedSetAggregate('weighted_power'); // integer, NOT NULL, default 0
$table->nestedSetAggregate('fire_count'); // integer, NOT NULL, default 0
$table->nestedSetAggregate('fire_max', type: 'min_max'); // nullable, for Min/Max
Float contributions need a non-integer column type. A listener's
contribution() returns int|float|null and the package threads floats
end-to-end through the maintenance pipeline. But the stored column
still has to accept them. If you declared the column as integer via
nestedSetAggregate() and your listener returns fractional values, the
DB will truncate at the write side and your column will drift.
Declare a decimal column manually for float-returning listeners:
// instead of $table->nestedSetAggregate('weighted_score'),
$table->decimal('weighted_score', 14, 4)->default(0);
// or for nullable Min/Max-style:
$table->decimal('weighted_max', 14, 4)->nullable();
Cast as float (or decimal:N) on the model:
protected $casts = [
'weighted_score' => 'float',
];
The aggregate machinery doesn't care which Blueprint helper produced the column — it cares only that the column exists with the declared name and accepts the value range your listener returns.
Method-override form
use Vusys\NestedSet\Aggregates\ListenerAggregate;
/** @return list<\Vusys\NestedSet\Aggregates\ListenerAggregateDefinition> */
protected function nestedSetListenerAggregates(): array
{
return [
ListenerAggregate::sum(WeightedPowerListener::class)->into('weighted_power'),
ListenerAggregate::max(FireMaxListener::class)->into('fire_max'),
];
}
Attribute and method-override forms can coexist; attribute declarations come first.
Listener AVG
Declare a listener AVG with AggregateFunction::Avg and the package
auto-promotes two internal companions — a Sum and a Count — that ride
the same listener. The display column is then written by the same
avg = sum / NULLIF(count, 0) SET clause that powers SQL AVG, so the
ancestor UPDATE stays a single statement.
#[NestedSetAggregateListener(column: 'weighted_avg', listener: WeightedPowerListener::class, operation: AggregateFunction::Avg)]
class Monster extends Model implements HasNestedSet { use NodeTrait; }
The companion columns are conventionally suffixed __sum and __count
on the AVG column name. You declare them in the migration alongside
the display column:
// Display column — nullable, fractional. Use decimal for fixed-precision
// or float for an approximate type. Cast on the model accordingly.
$table->decimal('weighted_avg', 14, 4)->nullable();
// Internal companions — integer (or decimal, if your listener returns floats).
$table->nestedSetAggregate('weighted_avg__sum');
$table->nestedSetAggregate('weighted_avg__count');
Cast all three on the model:
protected $casts = [
'weighted_avg' => 'float', // or 'decimal:4'
'weighted_avg__sum' => 'integer',
'weighted_avg__count' => 'integer',
];
The companions are tagged internal — getAggregateDefinitions()
filters them out, so they don't appear in user-facing introspection.
The listener's contribution() runs once per node per save and
produces both Sum and Count contributions in one call (Count adds 1
when contribution() returns non-null, 0 when it returns null).
The companion column names must follow the __sum / __count
convention — the auto-promotion always derives them from the display
column name, so renaming them isn't supported.
Exclusive listener aggregates
exclusive: true opts out of self-inclusion — a node's stored value
holds the function's rollup over its descendants only. A leaf's
exclusive aggregate is always the zero/null element for the function
(0 for Sum/Count, null for Min/Max). Same semantic as
exclusive: true on SQL aggregates;
the contribution per node still comes from contribution().
#[NestedSetAggregateListener(
column: 'descendants_weighted_power',
listener: WeightedPowerListener::class,
operation: AggregateFunction::Sum,
exclusive: true,
)]
class Monster extends Model implements HasNestedSet { use NodeTrait; }
In the method-override form, call ->exclusive() on the fluent builder
before ->into():
protected function nestedSetListenerAggregates(): array
{
return [
ListenerAggregate::sum(WeightedPowerListener::class)
->exclusive()
->into('descendants_weighted_power'),
];
}
Maintenance
Listener aggregates ride the same lifecycle hooks as SQL aggregates.
On each save the package calls contribution() on the changed node,
computes a delta, and propagates it up the ancestor chain. Min/Max
listener columns that may have been invalidated trigger a PHP-based
ancestor recompute — the package issues exactly two SELECTs (one to
load the ancestor chain, one to load every in-scope node under the
topmost ancestor) regardless of chain depth, then computes each
ancestor's new extremum in PHP. Listener contributions are cached per
node across all Min/Max definitions, so each contribution() call
runs once per node per recompute.
fixAggregates(), aggregateErrors(), and freshAggregate() all
cover listener columns:
Monster::fixAggregates(); // repairs SQL and listener columns together
Monster::aggregateErrors(); // counts drift in both column types
$node->freshAggregate('weighted_power'); // PHP-computed fresh value for one node
replicate() resets listener columns to 0 (Sum/Count) or null
(Min/Max) on clones, matching the SQL-aggregate behaviour.
Listener aggregate limitations
withFreshAggregates()does not cover listener columns — the collection-level fresh-read path is SQL-only. UsefreshAggregate('col')for a single node or repair the whole set withfixAggregates().fixAggregates()is O(N²) for listener columns — it loads every in-scope node and scans each node's subtree in PHP. UsewithDeferredAggregateMaintenance()for batch mutations to amortise the cost down to one pass.- Listener repair / Min-Max recompute holds the bounding-box subtree
in PHP memory.
fixAggregates()loads every in-scope Eloquent model; the Min/Max recompute path loads every in-scope node under the topmost affected ancestor. At N > ~100K nodes this is the more pressing constraint than CPU. AnchoredfixAggregates($subtreeRoot)and chunkedfixAggregates(chunkSize: …)both bound the working set. - Filters are encoded in the listener itself — there is no
filter:param on#[NestedSetAggregateListener]. Returnnullfromcontribution()to exclude a node, or0/1to count conditionally.