Cloning Subtrees
Duplicate a node and every descendant under a new parent — or as a new root — with fresh primary keys, regenerated structural columns (lft / rgt / depth / parent_id), and one deferred aggregate recompute. The whole clone runs in a single transaction; on failure nothing is committed.
cloneSubtreeTo() and cloneSubtreeAsRoot() are built on top of bulkInsertTree — they inherit its autoincrement / UUID parent-id reconciliation and deferred aggregate maintenance, and suppress per-row Eloquent creating / created / saving / saved events for the cloned rows. The single signal listeners hook is SubtreeCloned.
1. What it looks like
Two trees side by side make the structural story concrete. Starting state — a re-usable Template site lives alongside an empty Customer 42 site:
Template
Home
About
Team
Mission
Contact
Customer 42
A single call clones the whole Template subtree under Customer 42:
$template->cloneSubtreeTo($customer42);
After the call, Template is untouched — the clone is independent — and Customer 42 now carries a fresh copy of every descendant. Note the bound badges on each row (lft and rgt): the cloned rows occupy a brand-new slot range under Customer 42, and Template's own bounds shift outward only if siblings need to make room (they don't, here — Template and Customer 42 are roots and don't share an ancestor):
Template
Home
About
Team
Mission
Contact
Customer 42
Home
About
Team
Mission
Contact
Every cloned row has a new primary key, regenerated lft / rgt / depth / parent_id, and the same business attributes (name, etc.) as its source. Aggregates roll back up automatically; observers and per-row Eloquent created events are suppressed for the cloned rows in favour of one SubtreeCloned event when the transaction commits.
2. cloneSubtreeTo — clone under a parent
$template = Category::query()->where('name', 'Template')->first();
$target = Category::query()->where('name', 'Customer 42')->first();
$root = $template->cloneSubtreeTo($target);
// $root is the new root of the cloned subtree, already placed
// under $target as its last child.
The default is to append. Pass $position to control placement (same semantics as moveTo):
$template->cloneSubtreeTo($target, position: 'first');
$template->cloneSubtreeTo($target, position: 2); // 0-indexed
The optional $transform closure rewrites each row's raw attributes before insert. It receives the source row's attributes (no casts) plus the destination depth relative to the clone's new root (0 for the clone root, 1 for its direct children, …):
$template->cloneSubtreeTo($target, transform: function (array $attributes, int $depth): array {
$attributes['name'] = sprintf('[copy] %s', $attributes['name']);
return $attributes;
});
Structural columns (lft / rgt / depth / parent_id), scope columns, the primary key, and materialised-path columns are owned by the package — returning any of them from $transform throws LogicException. Aggregate columns are silently stripped (the deferred recompute fills them in).
3. cloneSubtreeAsRoot — clone as a new root
Same shape, but the clone lands at the top level of the source's scope:
$template->cloneSubtreeAsRoot();
$template->cloneSubtreeAsRoot(position: 'first');
$template->cloneSubtreeAsRoot(transform: $rewrite);
For scoped models the clone stays in the source's scope. To clone across scopes, edit the scope column in $transform — but you must call cloneSubtreeTo() with an explicit parent in the destination scope; cloneSubtreeAsRoot() always inherits the source's scope.
4. Static helper
Category::cloneSubtree($template, $target);
Category::cloneSubtree($template, $target, position: 'first', transform: $rewrite);
Convention is to pass the optional args by name. The static form is equivalent to $template->cloneSubtreeTo($target, ...).
5. Soft-deleted rows
By default, trashed source rows are silently skipped: a trashed root throws upfront and trashed descendants are omitted from the clone. Pass includeTrashed: true to materialise them as live rows on the destination side (deleted_at is always null on clones):
$template->cloneSubtreeTo($target, includeTrashed: true);
A trashed destination parent always throws — cloning into a deleted parent would orphan the new rows on restore / forceDelete().
6. What's preserved, what's regenerated
| Column kind | Behaviour on the clone |
|---|---|
| Primary key | Regenerated (autoincrement or UUID, per model config) |
Structural (lft, rgt, depth, parent_id) |
Regenerated to match the destination position |
| Scope columns | Copied from the destination parent's scope (or the source's, for cloneSubtreeAsRoot()) |
| Materialised path | Recomputed under the destination parent's path |
deleted_at |
Always null (clones are live) |
created_at / updated_at |
Refreshed to the clone's transaction time |
| Aggregate columns | Stripped on insert, then filled by the deferred recompute |
| Every other column | Copied verbatim (after $transform runs) |
7. Guards
| Condition | Throws |
|---|---|
| Source has no bounds (unplaced) | UnplacedNodeException |
| Destination parent has no bounds | UnplacedNodeException |
Source is trashed and includeTrashed: false |
InvalidArgumentException |
| Destination parent is trashed | InvalidArgumentException |
| Destination is in source's own subtree (including self) | InvalidCloneTargetException |
| Source and destination scopes differ | ScopeViolationException |
$transform returns a structural / scope / primary-key column |
LogicException |
8. Event
A single SubtreeCloned event fires after the outermost transaction commits, carrying the source root, the clone root, the row count, and the includeTrashed flag. See the events reference for the full payload.