Inserting & Moving Nodes

Every mutation is a method on the model that queues a pending operation; the actual work happens on the next save(), wrapped in a transaction (configurable, on by default). This page walks through each positional method using a single example tree, and shows what the tree looks like after each operation.

Building the example tree

// Step 1: root
$electronics = new Category(['name' => 'Electronics']);
$electronics->saveAsRoot();

// Step 2: append a child
$computers = new Category(['name' => 'Computers']);
$computers->appendToNode($electronics)->save();

// Step 3: append another — appendToNode always becomes the LAST child
$phones = new Category(['name' => 'Phones']);
$phones->appendToNode($electronics->refresh())->save();

After step 3:

Electronics
├── Computers     ← first child (was appended first)
└── Phones        ← last child

prependToNode — insert as first child

$audio = new Category(['name' => 'Audio']);
$audio->prependToNode($electronics->refresh())->save();
Electronics
├── Audio         ← prepended in front
├── Computers
└── Phones

insertBeforeNode / insertAfterNode — siblings

$accessories = new Category(['name' => 'Accessories']);
$accessories->insertBeforeNode($phones->refresh())->save();

$tablets = new Category(['name' => 'Tablets']);
$tablets->insertAfterNode($phones->refresh())->save();
Electronics
├── Audio
├── Computers
├── Accessories   ← inserted before Phones
├── Phones
└── Tablets       ← inserted after Phones

up / down — reorder among siblings

up() swaps with the previous sibling; down() swaps with the next. Both return true if a swap happened, false if there was no neighbour to swap with.

Tree corruption can mask "no neighbour" as a false return. Both methods look up the sibling via lft / rgt, so on a tree with gap corruption (e.g. a leaf hard-delete that mis-shifted bounds) the sibling query may return null even though a logical sibling exists. The methods can't distinguish "genuinely no neighbour" from "tree is broken". Pair persistent unexpected false returns with isBroken() / countErrors() to rule out structural corruption before assuming the row really is at an edge.

$audio->refresh()->down();    // Audio swaps places with Computers
Electronics
├── Computers     ← was second, now first
├── Audio         ← was first, now second
├── Accessories
├── Phones
└── Tablets

makeRoot — detach into a new tree

makeRoot() lifts a node (and its subtree) out of its current parent and reroots it as a standalone tree. saveAsRoot() is the same thing in one call.

$phones->refresh()->makeRoot()->save();
Electronics
├── Computers
├── Audio
├── Accessories
└── Tablets

Phones             ← own tree now

Sibling lookups (read-only)

$computers->refresh()->prevSibling();   // null — Computers is the first child
$computers->nextSibling()->name;        // 'Audio'

The refresh footgun

Pass a fresh copy of the parent / sibling (->refresh()) when you've inserted other rows since loading it.

The trait re-reads the target's bounds from the database before mutating, so the tree stays safe against stale parent_ids — but it can't refresh nodes you handed it. After any mutation, the in-memory copy you used as a target has stale lft / rgt values; the next mutation against that same instance must ->refresh() first or risk inserting at the wrong slot.

The asymmetry is deliberate: the target node is read fresh from the DB inside every mutation (the package owns that read), so a stale target instance can't cause wrong-slot inserts — but the moving node is the user's object and the package can't safely refresh it without clobbering pending in-memory changes you may want persisted. If you've held a reference to a parent / sibling across multiple mutations, refresh that reference; if you've only handed it to one appendToNode(...)->save(), you don't need to.

Cross-tree moves

appendToNode and friends accept any HasNestedSet of the same model class. Moving between scopes is rejected with ScopeViolationException — see Scoped Trees.