Maintenance
Aggregates ride the package's existing lifecycle events. The cost of any single mutation depends on which family the column belongs to.
1. One mutation, traced through the tree
Before the family taxonomy, here's the shape every delta-maintained aggregate follows. Take a budget tree with a maintained cost_total = SUM(cost):
Engineering
People
Salaries {cost=26000}
Bonuses {cost=2000}
Tools
SaaS {cost=2500}
Hardware {cost=1500}
Each node's Σ cost is the stored cost_total rolled up over its subtree. A single source-column update doubles Bonuses' cost — done through a model instance, so the saving/saved hooks fire and maintenance runs:
$bonuses->cost = 4000;
$bonuses->save();
Warning
Maintenance is driven by Eloquent model events. A bulk query-builder write — Account::query()->where(...)->update(['cost' => 4000]) — fires no model events, so the aggregate columns are not updated and drift silently. After any bulk update of a source column, run fixAggregates() (anchored, on scoped models) to resync.
After the write, every ancestor of Bonuses has cost_total += 2000. Sibling rows (Salaries, Tools, SaaS, Hardware) and the unrelated Engineering subtree are untouched — the delta only flows up the ancestor chain:
Engineering
People
Salaries {cost=26000}
Bonuses {cost=4000}
Tools
SaaS {cost=2500}
Hardware {cost=1500}
That's the delta-maintainable path in code form: one UPDATE cost_total = cost_total + 2000 WHERE lft <= Bonuses.lft AND rgt >= Bonuses.rgt, one round-trip, work proportional to the ancestor depth (2 rows: People, Engineering). The same shape applies to inserts (Δ = +new.cost), deletes (Δ = -old.cost), moves (Δ on old chain, +Δ on new chain), and soft-delete cascades. The families below describe what changes when the column isn't directly delta-maintainable — but the ancestor-chain pattern is the constant across all of them.
2. Families
| Family | Members | Per-mutation cost |
|---|---|---|
| Delta-maintainable | Sum, Count |
One UPDATE col = col ± Δ per ancestor chain. Constant work per row. |
| Companion-derived | Avg, Variance, Stddev, WeightedAvg, BoolOr, BoolAnd, GeometricMean, HarmonicMean |
Delta on the internal companions; the user-facing display column is rewritten in the same UPDATE from a formula over those companions. Single statement per ancestor chain. |
| Extremum-recompute | Min, Max |
Cheap delta when the change can't possibly invalidate the stored extremum (ascending update on Max, descending update on Min, insert of a non-extreme row). Otherwise SELECT-then-UPDATE recompute of the invalidated subset. |
| Chain-recompute | BitOr, BitAnd, BitXor, DistinctCount, StringAgg, JsonAgg, JsonObjectAgg |
Full subtree scan up the ancestor chain on every mutation that touches the source column. No signed delta is used (for BitOr/BitAnd none exists — a removed bit might still be held by another row; BitXor recomputes too for one uniform path). |
| Fresh-read-only | Median, Percentile |
No maintenance — no stored column. Each read is one correlated subquery per outer row per percentile. See Quantiles. |
3. Per-mutation table
The most common shapes:
| Mutation | Delta-maintainable / companion-derived | Min / Max | Bitwise (BitOr / BitAnd / BitXor) / collection |
|---|---|---|---|
| Insert leaf | 1 delta UPDATE on ancestor chain | 1 cheap-update; recompute only when the new leaf could become the extremum | Chain recompute on ancestor chain |
| Source-column update | 1 delta UPDATE | 1 delta when the change can't invalidate the stored extremum; SELECT-then-UPDATE recompute otherwise | Chain recompute |
| Delete | 1 delta UPDATE (negated Δ) | 1 delta when the deleted row wasn't the extremum; recompute otherwise | Chain recompute |
Move (appendToNode etc.) |
1 delta on old chain + 1 on new chain | Old-chain recompute when the moved subtree contained the extremum; cheap delta on the new chain | Recompute on both chains |
| Soft-delete + restore | Delta subtract on cascade, delta re-add on restored |
Same as delete/insert | Recompute on both |
MIN/MAX use a SELECT-then-UPDATE recompute path when the change may have invalidated the stored extremum — the same path applies to raw-filter columns whose filterRawWatches columns dirty on save. Concurrency between the SELECT and UPDATE is governed by the nestedset.aggregate_locking config flag ('auto' / 'always' / 'never'; see Configuration → aggregate_locking).
The chain-recompute families pay O(ancestor-depth × subtree-size-at-each-ancestor) per mutation. If your write path is hot and you have one of these declared on a deep tree, consider deferred maintenance — every save inside the closure skips the per-row aggregate update; one fixAggregates() runs at the end.
4. Integrity tooling
Mirrors the tree-repair API:
Category::aggregateErrors();
// ['articles_total' => 0, 'articles_count_all' => 0, 'articles_avg' => 0, ...]
Category::aggregatesAreBroken(); // bool
Category::fixAggregates();
// → AggregateFixResult { totalRowsUpdated: 0, perColumn: [...] }
fixTree() runs fixAggregates() as a final step — corrupted lft/rgt plus drifted aggregates are repairable in one call. The result carries the aggregate stats alongside the tree stats:
$result = Category::fixTree();
$result->nodesUpdated; // tree side — total size of the rebuilt set
$result->errors; // post-repair tree errors
$result->aggregatesFixed; // AggregateFixResult — null on no-aggregate models
Scoped models require an anchor on aggregateErrors, aggregatesAreBroken, and fixAggregates (same as fixTree).
5. Observability
Aggregate maintenance fires typed events on Laravel's event bus — FixAggregatesCompleted, FixAggregatesChunkCompleted, FixAggregatesJobDispatched, DeferredMaintenanceStarting, DeferredAggregateMaintenanceCompleted, NodeAggregatesRecomputed, NestedSetAggregateChanged, AggregateDriftDetected, AggregateMaintenanceFailed.
NestedSetAggregateChanged is the per-(row, column) CDC change feed for maintained aggregates — useful for mirroring to Redis / Kafka / search indexes without polling. It is opt-in by listener presence: the firing site short-circuits when nobody subscribes, so the package pays no extra read-side cost when the feed is unused.
See the Events page for the full payload reference, recipes, and queue-safety notes.
6. Adding aggregates to an existing model
- Add
#[NestedSetAggregate(...)]declarations to the model class. - Add
$table->nestedSetAggregate('col_name', type: ...)to a new migration; run it. The companion columns appear automatically — see Setup → type: reference for the complete shape table. - Add the matching cast to
$casts. - Run
YourModel::fixAggregates()once to backfill stored values from the source data. On scoped models, run per anchor. - Deploy.
After the backfill, every subsequent mutation through Eloquent keeps the stored values current.