Repairing Aggregates
fixAggregates() is fast on most trees but a heavily-drifted 1M-row
table still measures in tens of seconds — not the kind of work you want
on the synchronous response path. queueFixAggregates() hands it to a
worker instead:
// Fire and forget — uses Laravel's default queue connection / queue name.
Category::queueFixAggregates();
// Scoped models: anchor required (same rule as the sync method).
MenuItem::queueFixAggregates($anchor);
// Per-call routing overrides (also configurable globally — see below).
Category::queueFixAggregates(onConnection: 'redis', onQueue: 'aggregates-low');
Defaults come from config/nestedset.php:
'queue' => [
'connection' => env('NESTEDSET_QUEUE_CONNECTION'), // null → default connection
'queue' => env('NESTEDSET_QUEUE'), // null → default queue
],
The dispatched Vusys\NestedSet\Jobs\FixAggregatesJob carries the model
class and an optional anchor id; its handle() just calls the same
Model::fixAggregates($anchor) you'd call synchronously, so it inherits
every Phase K+ optimisation automatically. The job is idempotent — a
second run on a clean tree finds zero drift and writes nothing — so
dispatching defensively after a batch operation is safe.
Chunked self-redispatch
For very large trees where even a single repair job would exceed your
queue's per-job time budget, pass a chunkSize and the job will process
one bounded slice and re-dispatch itself with an advanced cursor until
the table is covered:
// Process 1,000 outer rows per dispatch. The job re-queues itself
// (on the same connection/queue) after each chunk until done.
Category::queueFixAggregates(chunkSize: 1_000);
Each chunk runs one chunked fixAggregates constrained to its outer-id
slice, so total work scales linearly in chunkSize regardless of total
table size. The chain terminates automatically when a chunk returns
fewer rows than chunkSize — no completion handler to register, no
manual cursor to track. Combine with a smaller chunk size to keep
individual jobs well under your worker's --timeout.
Deferred maintenance for batch mutations
If you're doing many small mutations through Eloquent — a CSV import, a
re-parenting script, a re-numbering migration — every save normally
triggers a per-row aggregate update on the ancestor chain. For N saves
that's N × ancestor-chain UPDATEs. withDeferredAggregateMaintenance()
suspends those side-effects for the duration of a closure and fires one
fixAggregates() at the end:
Category::withDeferredAggregateMaintenance(function () use ($csv, $parent) {
foreach ($csv as $row) {
$category = new Category($row);
$category->appendToNode($parent)->save(); // saving/created/saved fire,
} // aggregate side-effects deferred
}, $rootAnchor); // one fixAggregates($root) at the end
What still fires inside the closure:
- Every Eloquent event (
saving/created/saved/deleted/restoring/restored) - Mutators, casts, mass-assignment guards, observers — exactly as they would outside the block
What's deferred:
- The trait's per-row aggregate-column updates on the ancestor chain
(
articles_total,articles_count_all, etc.) - All the MIN/MAX recompute and AVG companion writes that normally piggy-back on each save
The wrapper is re-entrant (nested calls share one counter, only the
outermost call triggers the final fix) and failure-safe — if the
closure throws, the counter still decrements and fixAggregates() still
fires before the exception propagates. Leaving the table half-repaired
would be worse than spending the fix cost. The closure's return value
is what the wrapper returns.
Trade-off: this trades N small ancestor UPDATEs for one all-at-once repair pass. The repair touches every row whose stored aggregates may have drifted, so it's worth it when N is large (CSV imports, scripts) and a poor fit for one-or-two saves.
Sync chunked repair with progress
When you'd rather drive the loop yourself — e.g. a CLI command streaming
progress to stdout — pass the same chunkSize to the synchronous
fixAggregates() plus an onChunk callback:
$result = Category::fixAggregates(
chunkSize: 1_000,
onChunk: function ($chunkResult, int $chunkIndex, ?int $cursor) {
$this->output->writeln(sprintf(
'Chunk %d: %d rows updated (cursor=%s)',
$chunkIndex,
$chunkResult->totalRowsUpdated,
$cursor ?? 'end',
));
},
);
// $result is the merged total across every chunk.
The callback receives the per-chunk AggregateFixResult, the zero-based
chunk index, and the cursor (last id processed, or null on the final
chunk). Each chunk is independently atomic at the database level — if
the process is killed mid-loop you can re-run and the remaining drift
will be detected and repaired on the next pass.