Tree Repair
Production tables get corrupted — failed migrations, manual SQL surgery, bugs in old code. The repair toolkit lets you validate and rebuild:
Category::isBroken(); // bool
Category::countErrors();
// ['invalid_bounds' => 0, 'duplicate_lft' => 2, 'duplicate_rgt' => 0, 'orphans' => 1]
Category::fixTree(); // rebuilds lft/rgt/depth from parent_id
// → TreeFixResult { nodesUpdated: 15, errors: [...counts after repair...] }
On a scoped model, an anchor node is required so the repair stays inside one tree (this prevents accidental full-table walks on multi-million-row forests):
MenuItem::isBroken(); // ScopeViolationException — no anchor
MenuItem::isBroken($anyNodeFromThatMenu); // OK — scoped to that menu
MenuItem::fixTree($anchor); // repair one menu's tree
What gets corrupted, what's auto-fixable, and how to avoid it
The package treats parent_id as the source of truth. fixTree()
rebuilds lft/rgt/depth from a parent_id walk, so as long as
parent_id describes the tree you actually want, every other column is
recoverable.
| Corruption | Detected by countErrors()? |
Repaired by fixTree()? |
Typical cause |
|---|---|---|---|
invalid_bounds (lft >= rgt) |
✅ | ✅ | Raw UPDATE on lft/rgt; crashed transaction. |
duplicate_lft / duplicate_rgt |
✅ | ✅ | Concurrent gap-shifts without locking; partial migration. |
orphans (parent_id → missing row) |
✅ | ❌ — detected but not auto-repaired | Hard DELETE of a parent without cascading. |
parent_id cycles |
❌ — not surfaced by countErrors() |
❌ — cycle members are silently skipped | Raw UPDATE on parent_id that bypassed Eloquent guards. |
Aggregate drift (stored articles_total ≠ computed) |
✅ via aggregateErrors() |
✅ via fixAggregates() |
Raw UPDATE on the source column. |
Best practice in one rule: mutate trees only through Eloquent on a
NodeTrait model. Every appendToNode/prependToNode/insertBeforeNode/
insertAfterNode/makeRoot/delete/forceDelete/restore call is
wrapped in a transaction and maintains every invariant. Most of the
corruption categories above are reachable only by bypassing that surface.
See Corruption Reference for the full taxonomy with
worked recovery recipes, diagnostic SQL for finding cycles, and
tests/Feature/Corruption/ for executable examples of every category.
Limitations
fixAggregates() assumes a structurally-sound tree
The fresh-aggregate read path relies on the nested-set invariant
(i.lft >= o.lft AND i.lft <= o.rgt is equivalent to "i is a
descendant of o" only when every row's rgt is consistent with its
lft). Running fixAggregates() on a tree with invalid_bounds
or duplicate_lft/duplicate_rgt errors can produce stored
aggregates that disagree with what a healthy tree would compute.
The package's fixTree() runs fixAggregates() internally after
structural repair, so the recommended recovery order is the one
fixTree() enforces: structure first, aggregates second. Don't call
fixAggregates() standalone on a tree you know is structurally
broken.
fixTree($anchor) rebuilds only the anchor's subtree
When you pass an anchor to fixTree(), the rebuild walks down from
that anchor using parent_id and reassigns lft/rgt/depth for
every reachable descendant. Rows outside the anchor's subtree
are untouched.
If the anchor's subtree was corrupted in a way that changed its
total size (e.g. orphans were force-deleted leaving phantom gaps,
or descendants were added without rgt-shifting the ancestors),
the rebuilt subtree may overlap surrounding rows in the same scope.
In that case, fall back to the unanchored fixTree() which
rebuilds every row in scope from parent_id.