Architecture Overview
This section is a source-level walkthrough of how vusys/laravel-nestedset is built — the algorithms, the SQL, and how the classes collaborate. The task-oriented sections (Tree Operations, Querying, Aggregates, Maintenance) tell you what the package does; Internals tells you how it does it, so you can read the source, debug a corruption report, or contribute with confidence.
Note
Citations name the file plus the Class::method() — those are the durable anchors. Line numbers are a convenience and drift as the code changes. This walkthrough was captured against v0.13.0-26-gca9b1fb; if your checkout differs, trust the method names over the line numbers.
1. The shape of the package
Every public capability is reachable from one trait, NodeTrait (src/NodeTrait.php). A model opts in by using the trait and implementing the HasNestedSet contract:
use Illuminate\Database\Eloquent\Model;
use Vusys\NestedSet\Contracts\HasNestedSet;
use Vusys\NestedSet\NodeTrait;
class Category extends Model implements HasNestedSet
{
use NodeTrait;
}
NodeTrait is deliberately thin — it is a composition of nine concerns, each owning one slice of behaviour, plus a handful of Eloquent overrides. The trait body is little more than a list of use statements:
trait NodeTrait
{
use HasBulkInsert;
use HasNestedSetAggregates;
use HasNodeInspection;
use HasSoftDeleteTree;
use HasTreeExport;
use HasTreeMutation;
use HasTreeRelations;
use HasTreeRepair;
use HasTreeWalk;
// ...
}
Concern (src/Concerns/) |
Owns | Walkthrough |
|---|---|---|
HasTreeMutation |
appendToNode / prependToNode / insertBeforeNode / insertAfterNode / makeRoot / moveTo / up / down — queues a PendingOperation, flushed on save() |
The Mutation Engine |
HasTreeRelations |
parent, children, and the custom eager-loadable ancestors / descendants relations |
Query Engine & Relations |
HasNodeInspection |
isRoot / isLeaf / isDescendantOf / getSubtreeSize — pure in-memory predicates over the bounds |
The Nested-Set Model |
HasTreeRepair |
isBroken / countErrors / fixTree and the TreeFixResult value object |
Integrity & Repair |
HasNestedSetAggregates |
precalculated rollup columns (SUM/COUNT/AVG/MIN/MAX, filtered, listener-based) | Aggregate Maintenance |
HasSoftDeleteTree |
cascade soft-delete / restore with deleted_at stamp matching |
Aggregate Maintenance |
HasBulkInsert |
bulkInsertTree() — one makeGap + N saves + one deferred fixAggregates |
Bulk Insertion |
HasTreeExport |
toAsciiTree() / toMermaid() / toDot() / toJsonTree() tree serialisers, plus *Forest / *Scope static counterparts |
Tree Exporters |
HasTreeWalk |
walk() / dfs() / dfsPostOrder() / bfs() / flattenedSubtree() — visitor + generators over a loaded subtree, with WalkContext and WalkFilter |
Walking Subtrees |
1.1 The walker — src/Walker/
HasTreeWalk is thin glue; the real implementation lives in four value objects:
SubtreeWalkeris the engine. It takes a flat collection plus a root model, builds two indexes on construction (byKey: id => ModelandchildrenByParentKey: parentId => list<id>, bothO(N)), and exposes iterative-DFS and queue-BFS generators that yield(Model, WalkContext)tuples. Both DFS strategies use an explicit task stack so deep trees do not blow PHP's call stack. The walker also implementsCountableand exposesmaxDepth()/leafCount()companions — all three share a single-pass index walk and memoise.WalkContextis the readonly value object passed as the visitor's second argument. It carries depth (relative to the walk root, not the absolutedepthcolumn), parent, sibling index/count, the derivedisFirstSibling/isLastSiblingflags, and a lazypathToRoot()that walks up via the index on first call.WalkFilteris afinal readonlytriple ofmaxDepth,visitable,includeRoot. Named constructors (depth(),where(),compose()) and an instanceandThen()cover the common shapes. The sameWalkFilterplugs into every exporter's option object so a depth/predicate filter applied to ASCII output works identically against Mermaid and JSON.WalkSignalis a two-case enum (SkipSubtree,Stop) returned by visitor closures to steer the walk. Skip is honoured by pre-order DFS and BFS, ignored by post-order. Returningnull(orvoid) continues.
The walker is purely a consumer of in-memory data — it never queries. When the public methods on HasTreeWalk are called without an explicit $subtree, they fall back to $this->descendants; if the relation is not loaded either, they throw UnloadedSubtreeException. The exporters use it internally to compute their visible-key set when a WalkFilter is supplied.
Models must implements HasNestedSet (src/Contracts/HasNestedSet.php). The trait supplies a default implementation of every contract method, so the interface costs nothing to satisfy — its job is to give Larastan (and your IDE) a typed surface to resolve getLft(), getBounds(), and the column-name accessors against.
2. The layers underneath
The concerns are the API surface. They delegate the actual SQL to a layer of query builders under src/Query/:
model (NodeTrait)
│ appendToNode($parent)->save()
▼
HasTreeMutation ← queues a PendingOperation, dispatches on save()
│ newTreeMutator()
▼
TreeMutationBuilder ← emits the atomic CASE WHEN UPDATE (makeGap/moveNode)
│ new TreeExpression(...)
▼
TreeExpression ← backend-aware raw SQL fragment (no binding escaping)
▼
database
TreeBaseQueryBuilder— the package'sIlluminate\Database\Query\Buildersubclass; a home for SQL-execution hooks (e.g. the MariaDBoptimizer_switchprefix used by fresh-aggregate reads).TreeQueryBuilder— the Eloquent builder returned from everyNodeTraitmodel. AddswhereDescendantOf,whereAncestorOf,whereIsRoot,withDepth,defaultOrder,withFreshAggregates, and more.TreeMutationBuilder/TreeRepairBuilder— internal builders for the write and repair paths. They take a connection, table, and column names and emit the single-statement updates.TreeExpression— wraps a raw SQL string as anExpressionso the query builder splices it into theSETclause verbatim. It is also the backend-dialect generator (LATERAL on PostgreSQL/MySQL,STRAIGHT_JOINon MySQL, derived-table shape on MariaDB, correlated fallback on SQLite).
NodeTrait wires these in via two Eloquent overrides:
public function newEloquentBuilder($query): TreeQueryBuilder
{
return new TreeQueryBuilder($query);
}
protected function newBaseQueryBuilder(): TreeBaseQueryBuilder
{
$connection = $this->getConnection();
return new TreeBaseQueryBuilder(
$connection,
$connection->getQueryGrammar(),
$connection->getPostProcessor(),
);
}
Because newEloquentBuilder() is narrowed to return TreeQueryBuilder, every Category::query() chain exposes the tree scopes with full static analysis — no macros, no @method annotations.
3. Lifecycle wiring — bootNodeTrait()
The trait hooks into the Eloquent model lifecycle in bootNodeTrait() (src/NodeTrait.php). This single method is the spine of the whole package: the mutation engine, the aggregate maintenance, and the cascade logic all hang off these standard model events.
| Eloquent event | Hook | What it does |
|---|---|---|
saving |
callPendingAction() then captureAggregateDeltas() |
Dispatch the queued structural mutation; for existing rows, snapshot the aggregate deltas before the row updates. Also throws UnplacedNodeException if a new row reaches save() without being placed in the tree. |
saved |
applyAggregateDeltas() |
Issue the captured aggregate UPDATE up the ancestor chain. |
created |
applyAggregateOnCreate() |
Push a freshly inserted node's contribution to its ancestors. |
deleting |
re-read structural columns | Reload lft/rgt/depth/parent_id (+ scope columns) from the DB so the cascade and gap-close act on current values, not a stale in-memory copy. |
deleted |
soft cascade → force cascade → applyAggregateOnDelete() → applyStructuralCleanupOnDelete() |
Cascade the delete through descendants, decrement ancestor aggregates, then close the lft/rgt gap. |
restored |
restore cascade → applyAggregateOnRestore() |
Un-trash descendants, then re-add their contribution to ancestors. |
Each aggregate hook runs inside runAggregateHook(), a try/catch that dispatches an AggregateMaintenanceFailed event before re-throwing — so a failing rollup surfaces to Sentry/Bugsnag while still rolling back the wrapping transaction.
A detail worth internalising early: the saving hook guards against the most common footgun — calling Model::create([...]) or ->save() on a node that was never placed in the tree:
if (! $node->exists
&& method_exists($node, 'isPlacedInTree')
&& ! $node->isPlacedInTree()
) {
throw new UnplacedNodeException(/* ... */);
}
Without this, the row would land with lft = rgt = 0 (the migration default) and create an invalid_bounds corruption. The package fails loudly instead.
4. The aggregates subsystem
src/Aggregates/ is a self-contained subsystem the lifecycle hooks call into. There are three kinds of aggregate column, all sharing the same hooks:
- SQL aggregates — declared via
#[NestedSetAggregate]; SUM/COUNT/AVG/ MIN/MAX (and variance, bitwise, bool, geometric/harmonic mean, …) over a source column with optional filters. - Listener aggregates — declared via
#[NestedSetAggregateListener]; a PHPcontribution(Model $node)method computes each row's value. - Ad-hoc fresh aggregates —
withFreshAggregates([...]); no stored column, recomputed on read via a correlated subquery.
Maintenance picks one of two strategies per column — Strategy/DeltaMaintenance (a signed delta applied to ancestors) or Strategy/RecomputeMaintenance (a SELECT-then-UPDATE of the invalidated subset) — depending on whether the change can be expressed as a delta. The full machinery is covered in Aggregate Maintenance.
5. Scoping (multi-tree tables)
A single table can hold many independent trees ("scopes"). Declare the partition column(s) with #[NestedSetScope('menu_id')] or a getScopeAttributes() method. NestedSetScopeResolver (src/Scope/) derives the scope; every builder constrains its writes to the scope, and HasTreeMutation rejects cross-scope writes with ScopeViolationException. Scoped repair methods (fixTree, fixAggregates) require an anchor node so a repair stays inside one tree rather than walking a multi-million-row table. See Scoped Trees.
6. The service provider — schema macros
NestedSetServiceProvider (src/NestedSetServiceProvider.php) registers four Blueprint macros plus a per-connection hook:
$table->nestedSet(scope, cover, parentIdType)— adds the four columns (lft/rgtunsigned bigint,parent_idnullable,depthunsigned int) and one composite index.$table->dropNestedSet(...)— the inverse.$table->nestedSetAggregate('column', type: ...)— adds an aggregate storage column plus its companion columns (see Aggregate Maintenance).$table->dropNestedSetAggregate(...)— the inverse, dropping companions too.
The composite index column order is set by nestedSetIndexColumns():
return [
...self::toColumnList($scope), // scope columns first
$lft,
$rgt,
$parentId,
...self::toColumnList($cover), // covering columns last
];
Scope first means each tree occupies its own contiguous slice of the index; the cover tail lets subtree-aggregate subqueries (WHERE inner.lft >= outer.lft AND inner.rgt <= outer.rgt) run as covering scans with no heap visits. The provider also installs SQLite user-defined BIT_OR/BIT_AND/BIT_XOR aggregates on every fresh connection so the same native-aggregate SQL runs on all four backends.
7. Configuration
config/nestedset.php is small and worth reading in full once:
columns.*— override the four structural column names.auto_transaction(defaulttrue) — wrap every mutation in a transaction.aggregate_locking(auto/always/never) — controlFOR UPDATElocking on the recompute path. See Concurrency & Transactions.queue.*— connection/queue routing forqueueFixAggregates().events_enabled(defaulttrue) — short-circuit every telemetry firing site.
8. Load-bearing design decisions
These five choices explain most of why the code looks the way it does. Keep them in mind while reading the rest of this section.
8.1 parent_id is the source of truth
lft/rgt/depth are a derived index. fixTree() rebuilds the index from a parent_id walk — never the reverse. A corrupted index is always recoverable as long as parent_id is intact. → Integrity & Repair
8.2 Every shift is one atomic CASE WHEN UPDATE
Inserts, moves, and bulk repairs renumber many rows in a single statement, so the on-disk state never passes through an invariant-violating intermediate. → The Mutation Engine
8.3 depth is stored, not computed
It is maintained on every mutation, which makes level queries and aggregate ancestry scans cheap.
8.4 Mutations are queued, then dispatched on save()
appendToNode($parent) only records a PendingOperation; the write happens in the saving hook, so the gap, the INSERT/UPDATE, and the aggregate hooks share one transaction.
8.5 Telemetry is observable but optional
Every meaningful operation fires a typed event, gated by events_enabled and a per-event listener check so an unobserved event costs nothing. → Concurrency & Transactions
9. Where to go next
Read The Nested-Set Model first — it establishes the encoding and invariants every other page assumes. From there, the pages can be read in any order.