efureev/laravel-trees
Laravel Trees adds nested-set tree support to Eloquent models, making it easy to build, query, move, and reorder hierarchical data like categories or menus with efficient ancestor/descendant operations and a simple API.
Confirm the toolchain first: v7 requires PHP 8.5 (^8.5, up from >=8.4) and Laravel 13. If your CI image, Dockerfile or composer.json platform config still says 8.4, nothing else matters yet.
composer require fureev/laravel-trees:^7.0
php artisan vendor:publish --tag=trees:migrations # if you don't already have the columns
Then, in order:
docs/Concepts.md before anything else. It is new in this release and is the vocabulary the rest of the docs assume — nested set, bounds, depth, tree scope, and explicitly when a nested set is the wrong shape and a plain parent_id would serve better. Reading the API docs first is what produces the classic mistakes (isChildOf() misuse, ordering assumptions).MigrationGuide.md — it is deliberately short. Four items: three internal-only signatures, the HealthyChecker return shape, the Table::fromQuery() argument order, and one exception import.UseTree), and make sure the schema has the bound columns plus parent_id and, for scoped trees, tree_id.First use case — a category tree. Append a root, append children, move a subtree, delete one:
$root = Category::create(['name' => 'Root']);
$kids = Category::create(['name' => 'Kids'])->appendTo($root);
$kids->descendants; // ordered in tree order (v7)
$kids->isDirectChildOf($root); // true — one level, answered from parent_id
$kids->isDescendantOf($root); // true — bounds-based, any depth
$kids->delete(); // strategy-dependent; verify which one you configured
That is the whole loop: create, attach, read ordered, move, delete, verify. Everything below is detail on those six verbs.
Attaching and moving. Reach for appendTo() / prependTo() / appendToNode() rather than hand-writing bound arithmetic. The package's own UseNestedSet::shift() now updates both bounds in a single statement, picked by either bound — which means a move is atomic at the statement level in a way it previously was not. Still wrap multi-step moves in a transaction and know your isolation level; the single statement removes the intermediate inconsistent row state, not the need for a transaction around the whole move.
Deleting — pick the strategy explicitly. The headline defect fixed in v7 was a delete that re-parented someone else's children. Whatever the configured strategy is for your model, write a test that asserts the siblings of the deleted node are untouched, and one that asserts the children are either gone or re-parented as you intended. This is the kind of bug that shows up in production as a slowly corrupting tree, not as an exception.
Querying ordered trees. ancestors and descendants now come back in tree order on every path — including eager loading, where ancestors previously lost its ordering. Practical consequence: you can drop the orderBy('_lft') you scattered around, and ->with('ancestors') is now safe for breadcrumbs. Note the boundary: has(), whereHas() and withCount() build their own subquery and are not ordered — don't expect ordering to leak into those.
Breadcrumbs and bulk parent lookups. parentsByModelId() now works on single trees (it used to throw NotSupportedException); it cross-joins a subquery holding the target node and compares bounds. Use it to resolve parent chains for a whole collection in one query instead of N+1 walks up parent. Remove any try/catch (NotSupportedException) you wrote around it.
Health checks. HealthyChecker::check() now runs six checks, including three that close real blind spots:
RangeCheck — holes in the numbering, which is exactly what a subtree deleted by query leaves behind, and which every other check reads as perfectly nested.RootCheck — a second root, whose bounds need not collide with anything.MissingParentCheck — orphans, previously implemented but commented out of the list; the docs used to warn that an orphaned node reads as healthy.Wire this into a scheduled command and alert on isBroken(), not on the raw array. Run RangeCheck after any code path that deletes by query rather than by model.
Table descriptors for the query builder. Table::fromQuery() is now static, matching fromModel() and fromTree():
$table = Table::fromModel(Category::class); // or ::fromTree($root), ::fromQuery(...)
This is the seam for raw/query-builder work against a tree you don't want to hydrate: joins, aggregate counts, custom reports.
Local CI parity. composer gate runs PHPStan → PHPCS → PHPUnit in CI's order; composer gate:docker and the gate service in docker-compose.yml reproduce it without a local PHP 8.5 install. Make this the command you run before pushing anything tree-related — a broken bound invariant is not something PHPStan catches, but the 547-test suite is far more likely to.
isChildOf() does not mean "direct child." It compares bounds, so it answers "descendant at any depth." v7 adds isDescendantOf() (same check, honest name) and isDirectChildOf() (the one-level question, answered from parent_id). isChildOf() is unchanged and stays for BC — but if you were using it to mean "one level," you have a latent bug, not a v7 break.
Upgrade breaks are short but sharp:
HealthyChecker::check() returns six entries, not three. Any code comparing the whole array to a literal needs the new keys. isBroken() and getTotalErrors() are unaffected.DuplicatesCheck and WrongParentCheck now return a node count, not ordered-pair / triple counts. Zero still means healthy, but a dashboard that showed "47 errors" on a tree with one defect will now show "1." Re-baseline your thresholds before someone reads the drop as a fix.Table::fromQuery() being static means configuration applied before the call no longer carries over — the table is built by the factory. Existing instance-style calls still work.catch (InvalidConfigException $e) needs the import changed to Fureev\Trees\Exceptions\InvalidConfigException. Same short name, same message — and it now sits under the package's base exception, so a broader catch (Fureev\Trees\Exceptions\Exception) catches it too.Health checks may now report trees that were "healthy" yesterday. MissingParentCheck and RootCheck are newly active. Expect a first scheduled run to surface orphans and duplicate roots that have been sitting there for a while. Investigate them; don't suppress the check.
Debugging a corrupt tree. Run the checks individually rather than through HealthyChecker, so you know which invariant broke: RangeCheck for gaps, RootCheck for multiple roots, MissingParentCheck for orphans, DuplicatesCheck / WrongParentCheck for overlap. A gap in the bounds after a bulk delete is the most common finding and the least visible.
Type against Model&TreeModel. Contracts\TreeModel now describes essentially every method the traits add, so the delete strategies, relations and checks are callable on an intersection type without static analysis objecting. If you maintain your own helpers that take a tree node, type them Model&TreeModel and let PHPStan enforce it. The contract deliberately omits overridden Eloquent methods (getDirty(), newCollection(), newEloquentBuilder(), uniqueIds()) and the initialiser/booter — Model covers those. Nothing implements the interface and nothing runtime-checks for it, so widening it breaks no model.
Coding standards. .phpcs.xml no longer enforces member-variable naming, because Eloquent exposes columns as snake_case properties ($model->parent_id, $model->tree_id). If you copied the package's ruleset, drop the rule rather than renaming your properties; also note the docblock-on-every-property sniff is gone. The CI job is now Static Analysis & Coding Standards and actually runs PHPCS.
Extension points. Attribute::make() now declares make(AttributeType $name, FieldType $type = FieldType::UnsignedInteger) — safe for existing calls, useful if you build custom attribute sets and want static analysis to describe them. BaseRelation::addExistenceConstraint() replaced the protected relationExistenceCondition(), applying whereColumn() constraints instead of returning raw SQL; it had no callers, so this only matters if you subclassed a relation class.
One thing to internalise: v7 is an audit release, not a feature release. Most of what changed was quietly wrong — wrong connection on a bound shift, wrong children re-parented on delete, holes invisible to every check. Treat the upgrade as an opportunity to re-read your own tree code against docs/Concepts.md, and add a scheduled HealthyChecker run if you don't already have one, because you now have checks worth running.
How can I help you explore Laravel packages today?