Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Trees Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. Read 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).
  2. Upgrading from v6? Read 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.
  3. Add the trait to your model (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.

Implementation Patterns

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.

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator