vusys / laravel-nestedset
Nested set model implementation for Laravel 11+.
Requires
- php: ^8.3
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/events: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- brianium/paratest: ^7.0
- driftingly/rector-laravel: ^2.0
- infection/infection: ^0.33 || ^0.34
- larastan/larastan: ^2.9|^3.0
- laravel/pint: ^1.0
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpunit/phpunit: ^11.0|^12.0
- rector/rector: ^2.0
- vusys/laravel-runabout: dev-master
Suggests
- illuminate/bus: Required by Model::queueFixAggregates() — FixAggregatesJob uses Bus\Queueable and Foundation\Bus\Dispatchable.
- illuminate/queue: Required by Model::queueFixAggregates() — FixAggregatesJob is dispatched onto a queue.
Provides
None
Conflicts
None
Replaces
None
- dev-master
- v0.24.4
- v0.24.3
- v0.24.2
- v0.24.1
- v0.24.0
- v0.23.0
- v0.22.0
- v0.21.0
- v0.20.0
- v0.19.0
- v0.18.2
- v0.18.1
- v0.18.0
- v0.17.0
- v0.16.0
- v0.15.0
- v0.14.0
- v0.13.0
- v0.12.0
- v0.11.0
- v0.10.0
- v0.9.0
- v0.8.0
- v0.7.0
- v0.6.0
- v0.5.0
- v0.4.0
- v0.3.0
- v0.2.0
- v0.1.0
- dev-ci/fix-matrix-badge-count
- dev-chore/normalise-pint
- dev-ci/normalise-dependabot-auto-merge
- dev-chore/normalise-editorconfig
- dev-fix/concurrency-retry-jitter
- dev-fix/avg-display-double-rounding
- dev-ci/paratest-parallel
- dev-widen-restore-journey
- dev-runabout-tests
- dev-review-audit-fixes-2026-06-11
- dev-ci/guard-infection-distill-against-missing-checkout
- dev-test/close-mutation-gaps
- dev-misc-fixes
- dev-docs/filtered-widget-sample
- dev-chore/1.0-polish
- dev-lazy-aggregates-ttl
- dev-feat/aggregate-change-feed
This package is auto-updated.
Last update: 2026-10-12 15:22:51 UTC
README
A modern Laravel implementation of the nested-set model for hierarchical data — strict types throughout, PHPStan level 9, atomic CASE-WHEN mutations, multi-tree scoping, soft-delete cascade, live aggregate roll-ups (eager or lazy with TTL), subtree cloning, JSON tree import/export, materialised-path columns, and an opinionated repair toolkit.
use App\Models\BudgetItem; $root = new BudgetItem(['name' => 'Engineering']); $root->saveAsRoot(); $child = new BudgetItem(['name' => 'Salaries', 'cost' => 26000]); $child->appendToNode($root)->save(); $child->depth; // 1 $child->isLeaf(); // true $child->isDescendantOf($root); // true $child->parent; // $root (Eloquent relation, eager-loadable) $child->ancestors()->get(); // collection containing $root $root->refresh(); // re-read parent bounds after the append $root->descendants()->get(); // collection containing $child $root->getDescendantCount(); // 1 (descendants, excluding self; +1 for total nodes in subtree)
Declare aggregates on the model and the SUM / COUNT / AVG / MIN / MAX roll-ups are maintained automatically as the tree changes:
use Illuminate\Database\Eloquent\Model; use Vusys\NestedSet\Attributes\NestedSetAggregate; use Vusys\NestedSet\Contracts\MaintainsTreeAggregates; use Vusys\NestedSet\Export\AsciiOptions; use Vusys\NestedSet\NodeTrait; #[NestedSetAggregate(column: 'cost_total', sum: 'cost')] #[NestedSetAggregate(column: 'item_count', count: true)] #[NestedSetAggregate(column: 'avg_cost', avg: 'cost')] #[NestedSetAggregate(column: 'biggest_item', max: 'cost')] #[NestedSetAggregate(column: 'recurring_total', sum: 'cost', filter: ['recurring' => true])] class BudgetItem extends Model implements MaintainsTreeAggregates { use NodeTrait; protected $fillable = ['name', 'cost', 'recurring']; // so the mass-assignment below runs } // Render the forest with each node's own cost + rolled-up subtree total: $render = fn () => BudgetItem::toAsciiTreeForest(new AsciiOptions( label: fn ($n) => "{$n->name} (cost = {$n->cost}, total = {$n->cost_total})", )); echo $render(); // Engineering (cost = 0, total = 32000) // ├── People (cost = 0, total = 28000) // │ ├── Salaries (cost = 26000, total = 26000) // │ └── Bonuses (cost = 2000, total = 2000) // └── Tools (cost = 0, total = 4000) // ├── SaaS (cost = 2500, total = 2500) // └── Hardware (cost = 1500, total = 1500) // Operations (cost = 0, total = 1500) // └── Office (cost = 1500, total = 1500) BudgetItem::query()->where('name', '=', 'Bonuses')->first()->update(['cost' => 4000]); BudgetItem::query()->where('name', '=', 'Engineering')->first()->refresh()->cost_total; // 34000 — every ancestor updates (Bonuses → People → Engineering) // move the whole Tools subtree (3 nodes) under Operations — one statement BudgetItem::query()->where('name', '=', 'Tools')->first() ->moveTo(BudgetItem::query()->where('name', '=', 'Operations')->first()) ->save(); echo $render(); // Engineering (cost = 0, total = 30000) ← old parent shrank (Tools left, taking 4000) // └── People (cost = 0, total = 30000) // ├── Salaries (cost = 26000, total = 26000) // └── Bonuses (cost = 4000, total = 4000) // Operations (cost = 0, total = 5500) ← new parent grew by the whole moved subtree // ├── Office (cost = 1500, total = 1500) // └── Tools (cost = 0, total = 4000) // ├── SaaS (cost = 2500, total = 2500) // └── Hardware (cost = 1500, total = 1500) BudgetItem::query()->withFreshAggregates()->get(); // ad-hoc correlated recomputation (read-only — see Aggregates → Drift)
Why nested set?
The nested-set encoding stores lft and rgt integers on every node so any subtree, ancestor chain, or descendant set is a single BETWEEN query — no recursive CTEs, no N+1 loops. The price is that mutations (insert / move / delete) have to shift many rows to keep the lft/rgt sequence dense, so it's best suited to read-heavy hierarchies: category trees, menu structures, org charts, comment threads.
This package executes every shift as a single CASE WHEN UPDATE, so even a subtree move that touches thousands of rows is one round trip.
Installation
composer require vusys/laravel-nestedset
The service provider auto-registers Blueprint macros and registers a publishable config file:
php artisan vendor:publish \
--provider="Vusys\NestedSet\NestedSetServiceProvider" \
--tag=nestedset-config
See the Installation guide for the rest of the setup (migration macros, model trait, scoped trees).
Documentation
Full documentation lives at https://vusys.github.io/laravel-nestedset/.
- Getting Started — Introduction · Installation · Migration · Primary Keys · Model Setup
- Tree Operations — Inserting & Moving · Reordering Siblings · Soft Deletes · Bulk Insertion · Cloning Subtrees · Materialised Paths
- Querying — Tree Queries · Eloquent Relations · In-memory Tree Shaping · Walking Subtrees · Tree Exporters & JSON Import · Tree Diff · Inspection · Scoped Trees
- Aggregates — Overview · Setup · Reading · Declaring · Filtered · Collection · Listeners · Variance & Stddev · Weighted Avg & Booleans · Means · Quantiles · Top-K · Bitwise · Lazy · Recipes · Maintenance · Drift & Limitations
- Maintenance — Tree Repair · Repairing Aggregates · Corruption Reference
- Reference — Configuration · Testing Helpers · Factory Tree Builder · Transactions · Events · Production Notes · Glossary
- Internals — Architecture Overview
The site is built from the markdown in docs/ — if you spot an error, edit the source and open a PR.
Contributing
Run the full check suite before opening a PR:
composer pint:check # style composer rector:check # automated refactors composer analyse # static analysis composer test # unit + feature
All four must pass on CI before merge.
Changelog
See CHANGELOG.md for the release history and the pre-1.0 backwards-compatibility breaks called out per version.
License
MIT. See LICENSE.