webx-ui/nested-set
A Laravel package for managing hierarchical data with the nested set model, offering tree-friendly operations for building, querying, and organizing parent-child records in Eloquent models.
Architecture fit. Nested set (modified preorder tree traversal) is a mature storage pattern that stores lft/rgt bounds on each row so "this node and everything under it" becomes a single indexed range scan. For the feature classes the product roadmap keeps asking for — catalog categories with breadcrumbs, org/team hierarchies, nested menus, folder and permission trees, CMS page hierarchies — this is the correct shape provided reads dominate writes by a large margin. The package is a trait-based, MIT-licensed Eloquent extension, so it slots into an existing Laravel monolith with no new datastore, no new service, and no framework-level commitment. It is the right architectural answer to a well-defined problem class; the open question is whether this implementation is the right artifact to carry it.
Integration feasibility. Mechanically this is a low-ceremony install: composer require webx-ui/nested-set, add the trait to a model, add tree_id/parent_id/lft/rgt/depth columns with composite indexes, and confirm the trait's insert/move/delete surface. That's days, not weeks, for a first vertical slice. The friction is entirely in discovery, not mechanics: there is no meaningful README, no Packagist reputation to lean on, and therefore no documented contract for the trait name, required columns, method signatures, or transaction behavior. The first engineering task is a source read of vendor/webx-ui/nested-set/src/ and a parallel spike against kalnoy/nestedset, which is the de facto Eloquent standard. Budget a one-week evaluation spike, not a quarter.
Technical risk. The dominant risk is adoption profile, not code quality: zero stars, zero dependents, no CVE feed, no regression signal, no support path. Treat this as sample code you adopt and own, not a vendor dependency you consume. Secondary risks are concrete and predictable:
lockForUpdate() and per-tree serialization must be added by the integrating team.update(['parent_id' => ...]) bypasses the trait and leaves lft/rgt stale; soft deletes either leave numbering gaps or make bounds look non-contiguous under a global scope; cross-tree moves require gap-closing in the source tree and may not be supported at all.where('tree_id', ...) in any query is a cross-tenant data exposure, not just a wrong menu.UPDATE ... ORDER BY or locking hint in the source behaves differently on MySQL, Postgres, and SQLite; SQLite CI will pass while production fails.Key questions to close before committing.
| # | Question | Why it gates the decision |
|---|---|---|
| 1 | What is the trait name, and what columns/scope keys does the model have to declare? | Determines whether the migration convention is ours or theirs |
| 2 | Which methods exist for ancestors()/descendants()/siblings()/moveTo*()/makeRoot()? |
Defines the repository interface we can wrap |
| 3 | Does insert/move/delete run in a transaction and issue lockForUpdate()? |
If no, we own concurrency — a real scope cost |
| 4 | Is there a rebuild/validate entry point (command, static method, or both)? | Determines whether we can recover from corruption at all |
| 5 | How are soft deletes handled — gaps, or scope-aware bounds? | Classic trap, unlikely to be tested |
| 6 | Are cross-tree (tree_id → tree_id) moves supported? |
If not, reject the operation at the controller before UI exists |
| 7 | What PHP/Laravel version constraints, and what DB matrix does CI cover? | Compatibility claim we can't take on faith |
| 8 | Is webx-ui/nested-set the real published name and namespace? |
Zero-reputation install; verify before wiring in |
| 9 | Is kalnoy/nestedset API-compatible enough that the wrapper makes switching cheap? |
Makes the build-vs-buy call reversible |
Recommendation. Prototype it, but frame the decision as storage pattern adoption, not library adoption. The pattern is clearly correct for our read-heavy hierarchy surfaces; the specific package is unproven. Gate any customer-facing rollout on a working TreeRepository wrapper plus a passing parallel spike against kalnoy/nestedset — if the mature library's ergonomics are no worse, take it, because the maturity delta outweighs any small win here.
Stack fit. Laravel + Eloquent on MySQL or Postgres, inside the existing application, with no new datastore or runtime. That is the entire appeal: hierarchy support arrives as one trait and five columns rather than a service. The package is only viable if the team is already committed to Eloquent and to owning per-table integer bounds plus range-shift write logic — on a DB-agnostic or managed stack where that's operational overhead we don't want, the fit is worse than an adjacency list with recursive CTEs.
Migration path. Phased, and the scope column is non-negotiable from day one — retrofitting tree_id later means rebuilding every set.
tree_id (default 1), parent_id, lft, rgt, depth.(tree_id, lft), (tree_id, rgt), (tree_id, depth). A bare index on lft won't be used once tree_id is a predicate; verify with EXPLAIN on the actual descendants query, not just on row counts.parent_id in batched transactional passes ordered by parent chain.lft/rgt bounds.NOT NULL in a follow-up migration once validated.Compatibility. Two axes. Version compatibility — confirm PHP/Laravel constraints from composer.json and run the package's own tests against the database we actually deploy on. API compatibility — read the trait surface alongside kalnoy/nestedset and note where they diverge. Divergence is fine; undocumented divergence is not.
Sequencing.
ancestors() returns breadcrumbs in one query each, and a move preserves contiguity.kalnoy/nestedset behind the same interface. This is cheap and it is the whole point of the wrapper.TreeRepository (tree(), breadcrumbs(), move(), rebuild()). Controllers, admin screens, and Blade partials call the interface, never the trait. Swapping libraries later becomes a one-class change instead of a grep across the codebase.Two integration rules worth writing into the spec. Every tree query filters by tree_id explicitly — either in the repository or via a model global scope, but pick one and enforce it consistently. And structural writes are serialized: a single-worker queue or an advisory lock per tree_id, wrapped in DB::transaction with lockForUpdate() on the affected nodes. Do not assume the package handles either.
Maintenance. This is the largest ongoing cost and it should be budgeted explicitly. Pin the exact commit in composer.json, vendor the source into the repo, and name an internal owner with a written patch policy. Upgrades are deliberate porting exercises, not composer update. Because the wrapper isolates the trait, the blast radius of a swap is small — but the decision to maintain a fork is not free, and it needs a named owner before the first customer-facing surface ships.
Support. There is no upstream channel, no issue triage, no security advisory feed. Support is entirely internal: tier-1 through the TreeRepository interface, tier-2 through the named owner reading vendored source. Escalation path is "we fork it." That is acceptable for internal admin tooling and questionable for anything on the permission or tenancy boundary.
Scaling. Asymmetric, and the asymmetry is the whole contract.
($rgt - $lft - 1) / 2 — and a "show 15 descendants" widget is a count(*) with bounds, not a hydrate-and-walk. Subtree counts, depth filters, and "all descendants of X" exports become cheap SQL, which is where dashboards, permission rollups, and bulk exports get simpler. This is the payoff and it should be the stated reason for adoption.Failure modes to instrument for.
| Failure | Symptom | Detection |
|---|---|---|
| Bulk write bypasses the trait | Every subsequent subtree read is wrong | Code-review ban on raw updates to tree models; integrity job |
| Soft-delete gap or scope-aware bounds mismatch | Ancestor/descendant arithmetic breaks silently | Explicit delete-policy decision + tests; integrity job |
| Concurrent moves on one tree | Deadlock or corrupted bounds | lockForUpdate() + per-tree serialization; alert on lock waits |
| Cross-tree move attempted without support | Node vanishes from source tree or duplicates | Reject at the controller unless verified in source |
Missing tree_id predicate |
Cross-tenant data leak in a multi-tenant install | Repository-by-construction or global scope; query audit |
| Driver-specific raw SQL | Passes SQLite CI, fails production | Run CI against production DB engine |
| Bounds drift from any of the above | "The menu looks wrong" | Contiguous 1..2n preorder assertion per tree_id |
Ship an integrity command on day one. A scheduled job that walks each tree_id, asserts lft/rgt form a contiguous 1..2n preorder, and alerts on drift is the difference between a five-minute diagnosis and a day of on-call archaeology. Pair it with a documented rebuild escape hatch (php artisan nested-set:rebuild categories --tree=1) and a runbook entry covering blast radius and whether it can run while writes are paused. Rehearse it on staging before it's ever needed.
Ramp-up. Steep for the first engineer and near-zero for everyone after, if the wrapper lands first. Nobody should be reading the trait in application code. The onboarding artifact is the TreeRepository interface, the migration convention, the reorder-endpoint pattern, and the runbook — not the vendor source. The one exception is the named owner, who needs genuine fluency in the trait's transactional and scoping behavior, because that is where the unowned risk concentrates.
Net operational verdict. Read-heavy hierarchy surfaces: adopt the pattern, and adopt this package only behind the wrapper with a named owner and an integrity job in place. Write-heavy or concurrent reparenting, multi-parent structures, or anything requiring a security review and support path: use kalnoy/nestedset or an adjacency list with recursive CTEs. Small trees (hundreds of rows, two or three levels): use parent_id and assemble in memory — the optimization is premature and the operational overhead is real.
How can I help you explore Laravel packages today?