Bulk Tree Insertion
Seeding a large tree by calling appendToNode()->save() per node is
O(N²) — each save shifts every post-insertion-point row through a
CASE-WHEN UPDATE, so the gap-shift cost piles up over the whole
insert. bulkInsertTree() collapses that to one makeGap plus one
fixAggregates, while keeping every Eloquent guarantee (events,
mutators, casts, mass-assignment) and returning fully hydrated models:
$root = new Category(['name' => 'All categories']);
$root->saveAsRoot();
$root = $root->refresh();
[$electronics, $computers, $laptops, $desktops, $phones, $books] = Category::bulkInsertTree([
['name' => 'Electronics', 'children' => [
['name' => 'Computers', 'children' => [
['name' => 'Laptops'],
['name' => 'Desktops'],
]],
['name' => 'Phones'],
]],
['name' => 'Books'],
], appendTo: $root);
$electronics->name; // 'Electronics' — mass-assigned via $fillable
$electronics->wasRecentlyCreated; // true
$laptops->parent_id === $computers->getKey(); // true
The returned array is in depth-first pre-order — the same walk order
as defaultOrder() once the rows are persisted. Top-level entries
come first; each entry is immediately followed by its descendants.
Seeding new roots (unscoped models)
Passing appendTo: null on an unscoped model seeds the input as
new root(s) — the package starts one past the current MAX(rgt) so
the new trees never overlap existing ones. Useful for first-run
fixtures or admin-imported batches that should each become their own
top-level tree:
[$site1, $site2] = Category::bulkInsertTree([
['name' => 'Site 1', 'children' => [['name' => 'Home']]],
['name' => 'Site 2'],
]);
$site1->isRoot(); // true
$site2->isRoot(); // true
Scoped models reject appendTo: null with ScopeViolationException —
the anchor is also where the scope-column values come from.
Each row goes through a normal save(), so per-row creating /
saving / created / saved events still fire, every cast applies,
observers run, mass-assignment guards are respected. The only
operations the package does on top of save() are the one-shot
makeGap (replaces N gap-shifts) and the deferred fixAggregates
at the end of the call (replaces N aggregate-ancestor UPDATEs).
Performance
| Backend | N=100 (vs naive) | N=1000 (vs naive) |
|---|---|---|
| SQLite | 18ms (3.2× faster) | 224ms (3.2× faster) |
| MySQL 8 | 44ms (3.0× faster) | 302ms (6.0× faster) |
| MariaDB | 48ms (6.6× faster) | 451ms (11.9× faster) |
The win widens as N grows because the naive path's gap-shift cost is
O(N²). At N=10,000 on MariaDB the naive loop runs for many minutes;
bulkInsertTree scales roughly linearly.
Event-free seeding
If you specifically need event-free seeding (e.g. backfilling 100K rows from a CSV with no observer side effects), the standard Laravel escape hatch composes:
Model::withoutEvents(static fn () => Category::bulkInsertTree($rows, appendTo: $root));
Constraints
- Rows must not contain
lft,rgt,depth,parent_id, or the primary key — those are computed by the package. - Scoped models (those with
#[NestedSetScope]orgetScopeAttributes()) require an$appendToanchor — the scope-column values are copied from it onto every inserted row. - Wrapped in a transaction; if any per-row
save()throws, the gap-open and any prior inserts roll back together.