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 nested-array payload above translates directly to this tree under $root after the call. The lft / rgt slots are assigned in one pass — no incremental gap-shifting, no N round-trips:
All categories
Electronics
Computers
Laptops
Desktops
Phones
Books
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. The lft / rgt badges on each row above show the slot range each subtree occupies; reads the package issues against this tree (whereDescendantOf, ancestors(), etc.) are all BETWEEN queries over those ranges.
1. 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
To seed new roots on a scoped model, pass the target scope explicitly via the $scope argument — Model::bulkInsertTree($tree, null, ['tenant_id' => 7]) — and the package scopes the root placement to that tenant. Passing neither an anchor nor $scope on a scoped model still throws ScopeViolationException (the scope-column values have to come from somewhere).
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).
2. 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.
3. 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));
4. Constraints
4.1 Structural columns are reserved
Rows must not contain lft, rgt, depth, parent_id, or the primary key — those are computed by the package.
4.2 Scope columns are silently overwritten with the anchor's values
On scoped models, every inserted row's scope-column attributes are replaced with the values read off $appendTo regardless of what the input row contains. This is by design (a bulk insert into one anchor's subtree always belongs to that anchor's scope) but worth knowing — passing ['tenant_id' => 99] in a row when $appendTo->tenant_id === 7 produces a row with tenant_id = 7, not an error.
4.3 Scoped models require scope context
Models declared with #[NestedSetScope] or getScopeAttributes() need scope-column values from somewhere — either an $appendTo anchor (its scope is copied onto every inserted row) or, when seeding new roots with no anchor, an explicit $scope argument (bulkInsertTree($tree, null, ['tenant_id' => 7])). Passing neither throws ScopeViolationException.
4.4 Transactional
Wrapped in a transaction; if any per-row save() throws, the gap-open and any prior inserts roll back together.
5. Test fixtures and seeders
For tests and seeders, prefer the Factory Tree Builder — a factory mixin (BuildsNestedSetTrees) that hands bulkInsertTree the same payload you'd assemble manually, plus factory state, per-row customisation, and afterCreating integration. Category::factory()->tree(depth: 3, branching: 2)->create() builds a 15-node tree in one call with the same three-statement budget.