Skip to content

Improve Nested Set correctness and scale eager loading and repair - #58

Merged
binaryfire merged 26 commits into
0.4from
upstream-sync-nested-set-reconciliation
Oct 8, 2026
Merged

binaryfire merged 26 commits into
0.4from
upstream-sync-nested-set-reconciliation

Conversation

@binaryfire

@binaryfire binaryfire commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

This improves Nested Set correctness, performance and static analysis. It keeps tree changes consistent through failed saves and vetoed deletes, makes eager loading and repair scale to larger trees, and preserves model types across builders, relations and collections.

It also includes two framework changes. SoftDeletes::forceDelete() now resets its state when the delete throws, and PHPStan now understands the builder macros added by soft deletes and Scout.

The node and scoped node tests now run on MySQL, MariaDB and PostgreSQL as well as SQLite, with integer and UUID keys, against a prefixed connection.

Saving and moving nodes

  • A save cleared its pending structural action before running it, so a retry after a failure did something different. A scoped node saved before its scope was set lost the action, a node rejected for a parent in another tree became a root on the next save, and after an observer veto and rollback, saving a moved node again wrote only its new parent_id. The pending action now stays until it runs, and save() and saveOrIgnore() put it back when the save returns false or throws, unless an observer queued a new one.
  • Internal reloads of a node's bounds read plain rows on the write connection. They no longer fire retrieved listeners and still throw ModelNotFoundException for a missing row. The builder's depthForPosition() reads the same way.
  • getNodeData() passed its columns to first(), which ignores them once a global scope has selected columns. Called directly on such a model, it returned the scope's columns instead of the bounds and depth. Its column and key references were also unqualified, so a join with another id column made the lookup ambiguous. It now selects the structural columns and filters on the key, qualified with the query's table or alias, keeping the query's other constraints. The package's own moves were not affected: they pass the node's data in or use the scope-free lookup query.
  • create() re-read every new node after inserting it. It now re-reads only when it created children, whose inserts widen the node after the last append refreshed it.
  • columnPatch() rendered a zero offset as invalid SQL ("_lft"0). It now renders + 0.
  • rawNode() and setDepth() store a missing depth as 0 instead of writing null into the non-null depth column.
  • Assigning parent_id through setAttribute() returns the model.
  • Assigning a new parent_id forgets a parent relation loaded before it. The new parent is only looked up when the node is saved, so $node->parent used to return the old parent until then.
  • whereDescendantOf() with a key looked up the node through a fresh model query, which always read from the replica even when the outer query used useWritePdo(). It now uses the builder's own lookup query.
  • rebuildTree() mass assigned each new node's scope attributes, so a scope column missing from $fillable was dropped and the node could not join the rebuilt tree. The scope is now set directly, over the model's defaults.
  • A model's static $builder property is now honored after #[UseEloquentBuilder], and naming the Nested Set QueryBuilder itself in the attribute no longer throws. Any other builder must extend it.

Deleting and restoring nodes

  • Hard deletes removed the node's row before its descendants, so a restricting foreign key on parent_id rejected every subtree delete. Descendants are now removed children first, in descending _lft order because MySQL and MariaDB check the key per row, and only after deleting observers allow the delete. A veto leaves the subtree untouched. rebuildTree(delete: true) removes nodes in the same order.
  • Tree upkeep ran in five boot listeners, and a custom $dispatchesEvents listener that returned a value skipped them. A save could then leave null bounds, and a delete could leave descendants with crossing intervals. fireModelEvent() now owns the upkeep for saving, deleting, deleted, restoring and restored, keeping the framework's handling of quiet operations, $halt and listener results.
  • Soft deletes and restores skipped descendants hidden by ordinary global scopes. Cascades now use the scope-free nested set query.
  • Hard deleting a node and one of its descendants in one destroy() or forceDestroy() call threw ModelNotFoundException, because the ancestor's delete had already removed the descendant's row. That row's deleting now returns false, so the call counts only the rows it deleted.
  • With shouldFireDescendantEvents() enabled, restoring now runs descendant model events too, parents first, in the same bounded chunks deletion uses (children first). getDescendantDeleteChunkSize() is renamed getDescendantChunkSize(), a short chunk ends the loop, and a vetoed descendant throws.
  • SoftDeletes::forceDelete() now resets $forceDeleting in a finally block. Before, if the delete threw, a later delete() on the same model force deleted instead of soft deleting.

Transactions stay with the caller. A structural save updates other rows' bounds before saving observers run, so a veto or a saveOrIgnore() conflict can return false after the tree changed, and saveOrFail() commits in that case. The guide now shows how to wrap these writes in a transaction and lock the tree.

Eager loading

Eager loading ancestors, descendants or siblings for many parents was slow or failed outright. Each parent added an or group to the query, and every result was compared with every parent in PHP. On an 11,111-node tree, matching ancestors for every node took 50 seconds and matching descendants ordered by name over 58 seconds. The ancestor query itself took 28.5 seconds on MySQL, and SQLite rejected loads of about 1,000 or more parents because it limits expressions to 1,000 levels.

  • Ancestors use one constraint per scope: the parents' outer bounds plus a balanced CASE that finds the nearest parent right bound at or after each row's left bound.
  • Descendant and sibling groups are nested in balanced or groups of at most 64. Parents with no room for descendants are skipped, so loading descendants for leaves runs no query.
  • Validated integer bounds are inlined, as whereIntegerInRaw() does.
  • Matching receives all parents at once through matchMany(), which replaces the per-parent hooks: one stack sweep per scope for ancestors, a binary search over sorted buckets for descendants, and scope and parent buckets for siblings. A custom result order is restored only when the results were re-sorted.

On the same tree, ancestor matching takes 77 ms and descendant matching 134 ms. The ancestor query takes 176 ms on MySQL, and on PostgreSQL it drops from 4.7 seconds to 80 ms. siblingsAndSelf drops from 527 ms and 205 MB to 24 ms and 2.3 MB.

One cost remains: SQLite prepares a query with thousands of separate descendant ranges in quadratic time. The guide warns about it.

getAncestors(), getDescendants() and getSiblings() still run fresh queries rather than returning a loaded relation. linkNodes() and toTree() no longer clear a parent that was eager loaded from outside the collection, so reading it doesn't run another query.

Indexes

The left-bound index now includes the right bound, so ancestor queries check both bounds within the index. The schema helpers create (scope..., _rgt), (scope..., _lft, _rgt) and (scope..., parent_id, _lft), and dropNestedSet() drops the same set.

On 50,000-node trees, ancestor reads are about 1.5x faster on MySQL and MariaDB and about 3x faster on PostgreSQL and SQLite, and SQLite move ranges about 4x faster. The tradeoff: large gap writes are 5 to 15 percent slower on MySQL and SQLite, the PostgreSQL index is about 5 percent larger, and on MySQL a low-level moveNode() without a target depth spends about 5 ms instead of 0.6 ms looking it up. Descendant, child, sibling and max reads are unchanged.

Diagnostics and repair

  • fixTree() and fixSubtree() hydrated every node they selected. They now read plain rows on the write connection, compare each row's bounds, parent and depth, and hydrate and save only rows that change, so saving and saved observers and extraColumns still apply to those. Repairing a healthy 111,111-node tree went from 2.7 seconds and 367.5 MB to 358 ms and 73.2 MB on MySQL, and from 3.3 seconds to 389 ms on PostgreSQL. A damaged 11,111-node tree with 11,110 changed rows went from 9.5 seconds and 60.8 MB to 7.0 seconds and 7.4 MB on MySQL.
  • When a subtree grows, rows shifted by the gap update are compared at their shifted values, so rebuilds no longer save unchanged children twice.
  • countErrors(), getTotalErrors() and isBroken() now follow the caller's useWritePdo().
  • Diagnostics keep their SQL checks. Aimeos now counts errors by scanning rows in PHP, which uses 234 MB on the same tree; the SQL checks use 0.5 MB.
  • fixSubtree() accepts null, as upstream's does.

Static analysis

  • QueryBuilder, the relations and the collection are generic over the model. HasNode binds HasBuilder<QueryBuilder<static>>, so calls such as Category::whereIsRoot() and Category::query()->root() resolve, custom builders keep their type, and collections keep their model.
  • PHPStan reported the builder macros added by SoftDeletingScope and Scout's SearchableScope (withTrashed(), onlyTrashed(), restore(), searchable() and the rest) as undefined on builders and relations, and static model calls dropped custom builders. A small resolver maps a model trait to an interface declaring its macros through the eloquentBuilderMacros parameter. The database extension maps SoftDeletes, and Scout ships a new extension.neon mapping Searchable. Fluent macros return the builder or relation they run on, macros take precedence over same-named scopes as in Builder::__call(), and native builder methods still win.
  • The development PHPStan requirement is now ^2.3 in the root, database and foundation packages. PHPStan 2.2.16 can't follow assertThrows()'s control flow and reports a false error.

Documentation

The Nested Set guide now covers adding nested set columns to an existing table, custom column names, deleting nodes, and transactions and concurrency. It also adds relationship-based creation, neighbor moves, ancestor order, related-model subqueries, stored depth, flat trees, scoped key lookups, the soft-deleted leaf rule and typing a custom builder for static analysis.

The README records the deliberate differences from upstream, including null-only roots, the typed schema macros, stored depth, the diagnostics error keys and the changed relation hooks. The license credits Aimeos and Lunar's maintainers, and docs/upstream-sync/sync.yaml tracks Aimeos master as the package's upstream.

Tests

The node and scoped node tests are split into shared bases with integer and UUID key classes, plus thin subclasses for MySQL, MariaDB and PostgreSQL. Each class migrates its schema once instead of before every test. Assertions that relied on unspecified row order now compare sets or use defaultOrder(). Upstream tests from Aimeos, the original package and Lunar are ported under their upstream names, the schema test checks the exact columns and indexes on every database, and a custom-column fixture exercises creating, moving, deleting and repairing a tree through renamed columns.

Note

Improve nested-set correctness, eager loading, repair, and descendant events

  • Reworks HasNode::fireModelEvent in HasNode.php so tree maintenance runs from model lifecycle events, pending actions survive failed saves, and hard/soft descendant deletion and restoration are chunked and evented via changeDescendantsWithEvents
  • Rebuilds eager loading in BaseRelation and the ancestors/descendants/siblings relations to batch all parents into one scope-aware query with balanced OR groups, and match results in bulk while restoring original query order
  • Repairs fixSubtree in QueryBuilder.php to work on plain rows, save only changed placements, treat numeric strings as integers, and reconcile gap shifts; depth/distance constraints now accept zero
  • Adds a PHPStan BuilderMacroResolver that maps traits like SoftDeletes to macro interfaces (see SoftDeletingMacros.php and SearchableMacros.php) so soft-delete and Scout builder macros type-check correctly; PHPStan dev constraint raised to ^2.3
  • Changes the nested-set schema index to include both left and right bounds, and adds extensive shared test harnesses, UUID/scoped variants, database integration wrappers, and type fixtures
  • Risk: ensureConcreteNestedSetScope no longer accepts a Model argument, fixSubtree requires a nullable root, and the schema macro now drops/creates a different composite index — out-of-tree callers of these symbols will break

Macroscope summarized ac916a3.

The node tests ran only on SQLite with integer keys and unprefixed
tables. The shared cases now live in an abstract NodeTestBase with two
concrete classes: NodeTest for integer keys and NodeUuidTest for UUID
primary and parent keys, matching Aimeos' test layout. Both use a
prefixed connection, so the raw nested set SQL is checked against
prefixed tables. Cases that only make sense for integer keys, such as
a zero parent key, stay in NodeTest. Thin subclasses in
tests/Integration/NestedSet/Database run both classes on MySQL, MariaDB
and Postgres.

Each class migrates its fixture schema once. ResetRefreshDatabaseState
clears the migration state at both class boundaries, so the integer and
UUID schemas never reuse each other's tables and a later class still
migrates its own. The table one test used to create in the middle of a
test is now a fixture migration. Rebuilding the schema before every
test cost about 0.6 seconds per test on MySQL and MariaDB.

Seeding moved from setUp() into afterRefreshingDatabase(), which runs
inside the test coroutine and its transaction; MySQL failed when it ran
outside the coroutine. The Postgres sequence reset now uses the prefixed
table name.

Several assertions depended on row order that the query does not set,
which held on SQLite but not reliably on other databases. Tests that
check which rows come back now compare sets. Tests that pick particular
rows order the query with defaultOrder(). The root used to check that a
root is not a leaf could previously be store_2, which is a leaf. The
next-node test now calls getNextNode(), matching the previous-node test.

Expected SQL now uses the active grammar's quoting, and expected error
messages use the active connection name. Helpers and data providers
have docblocks and typed parameters, and callbacks declare their types.

Ports the test changes from Aimeos #2 (UUID keys), 28e6a4e066 (test
layout), 1e225e5b14 (removal of the all() helper) and #21 (all
supported databases), and the multi-database test setup from Lunar #2,
at Aimeos 90ea384feb and Lunar 12419691f0.

Validation: NodeTest and NodeUuidTest pass on SQLite, MySQL 8.4,
MariaDB 11 and Postgres 17. Each database directory passes serially
and under ParaTest, as does the full tests/NestedSet suite, and
php-cs-fixer reports no changes.
The scoped node tests ran only on SQLite with integer keys and
unprefixed tables, and rebuilt the schema before every test. They now
follow the node test layout: an abstract ScopedNodeTestBase with
ScopedNodeTest for integer keys and ScopedNodeUuidTest for UUID primary
and parent keys, matching Aimeos' test layout. Both use a prefixed
connection, and thin subclasses in tests/Integration/NestedSet/Database
run both classes on MySQL, MariaDB and Postgres.

Each class migrates its fixture schema once, with
ResetRefreshDatabaseState clearing the migration state at both class
boundaries. Two tests changed the schema inside the test: one created a
table with a nullable scope column and one added a deleted_at column.
On MySQL and MariaDB that schema change commits the test transaction,
so both are fixture migrations now. The two tests stay in
ScopedNodeTest, since their fixtures use integer keys.

Seeding runs in afterRefreshingDatabase(), inside the test coroutine
and its transaction. The Postgres sequence reset moved to
ScopedNodeTest and now uses the prefixed table name.

Two tests took the first row of a query with no order, which held on
SQLite but not reliably on other databases. They now order the query
with defaultOrder(). Expected error messages use the fixture class, and
callbacks and data providers have types and docblocks.

Ports the scoped test changes from Aimeos #2 (UUID keys), 28e6a4e066
(test layout) and #21 (all supported databases), and the multi-database
test setup from Lunar #2, at Aimeos 90ea384feb and Lunar 12419691f0.

Validation: ScopedNodeTest and ScopedNodeUuidTest pass on SQLite,
MySQL 8.4, MariaDB 11 and Postgres 17. Each database directory passes
serially and under ParaTest, as does the full tests/NestedSet suite,
and php-cs-fixer reports no changes.
Hypervel already caches soft-delete detection per model class through
Model::isSoftDeletable(), which HasNode::usesSoftDelete() uses, and
caches node trait detection per class in NestedSet::isNode(). Aimeos
added a test for each cache.

NestedSetCacheTest ports both. It checks that each model or plain class
is recorded once with its result, and that NestedSet::flushState()
clears the node class cache.

Ports Aimeos fb1545e9d9 and 622a2bbf99 (cache tests) and 1fd8b7ccc0
(no deprecated reflection calls) at Aimeos 90ea384feb.

Validation: NestedSetCacheTest and the tests/NestedSet suite pass
serially and under ParaTest, and php-cs-fixer reports no changes.
HasNode::newEloquentBuilder() used the #[UseEloquentBuilder] attribute
but ignored a model's static $builder property, so a model declaring a
custom nested set builder that way still received the base QueryBuilder.
Naming the nested set QueryBuilder itself in the attribute also threw,
because the check required a subclass.

Builder resolution now follows the attribute, then a non-default
static $builder, then the nested set QueryBuilder. Any resolved class
must be the nested set QueryBuilder or extend it; otherwise a
LogicException names the model and the required builder.

The builder tests cover the attribute, the attribute naming the nested
set builder, the property, attribute precedence over the property, and
rejection of incompatible builders from either source. The node class
cache test moves to NestedSetCacheTest, which covers the same cache.

Ports Aimeos ddbd084faf (respect #[UseEloquentBuilder]) at Aimeos
90ea384feb, extended to the builder property.

Validation: NestedSetTest and the tests/NestedSet suite pass serially
and under ParaTest, php-cs-fixer reports no changes and composer
analyse reports no errors.
The nested set schema indexed the left bound alone, so ancestor queries
had to read each candidate row to check its right bound. The left-bound
index now also holds the right bound, matching Aimeos' layout: the
schema helpers create (scope..., _rgt), (scope..., _lft, _rgt) and
(scope..., parent_id, _lft), and dropNestedSet() drops the same set.

Measured on 50,000-node random trees, scoped and unscoped, on MySQL
8.4, MariaDB 11, Postgres 17 and SQLite: ancestor reads are about 1.5x
faster on MySQL and MariaDB and about 3x faster on Postgres and SQLite,
and SQLite move ranges are about 4x faster. Large gap writes are about
5 to 15 percent slower on MySQL and SQLite, with no measurable change
on MariaDB and Postgres. On MySQL, the depth lookup for a parent with
no loaded depth rises from about 0.6 to 5 ms. Descendant, child,
sibling and max reads are unchanged.

The schema test ran only on SQLite, and NestedSetDatabaseTestCase
repeated part of it on each database. NestedSetSchemaTest now runs on
all four databases with a prefixed connection and DatabaseMigrations.
It checks every macro's key, bound and depth column types and the
exact non-primary indexes, with and without scope columns, and checks
that dropNestedSet() leaves only the original columns and no extra
indexes. The duplicated schema tests and tables leave
NestedSetDatabaseTestCase. The test bases read the default connection
name with the typed config getter.

Hypervel keeps its typed schema macros, which add the depth column and
indexes, instead of Aimeos' key column and type parameters and its
separate depth and index macros. The README records the difference,
the service provider notes where the omitted macros would sit, and the
guide describes the indexes and their write cost.

Ports Aimeos 52aee35ad3, 05f77e28e5, f504d66247 and a5c0d0c92c
(indexes), 90ea384feb (schema test) and #2 (UUID schema), Lunar #2
(ServiceProviderTest, native types and the getDefaultColumns() return
type), at Aimeos 90ea384feb and Lunar 12419691f0.

Validation: NestedSetSchemaTest passes on SQLite, MySQL 8.4,
MariaDB 11 and Postgres 17, as do the node, scoped and database test
classes. Each database directory passes serially and under ParaTest,
as does the full tests/NestedSet suite. php-cs-fixer reports no changes
and composer analyse reports no errors.
The package is ported from aimeos/laravel-nestedset, whose MIT license
notice in its README reads "(c) 2026 Aimeos and contributors" alongside
Alexander Kalnoy. The MIT license requires that notice in copies, so
the Nested Set license now includes the Aimeos line between the
original author's and Hypervel's.

Source: Aimeos README license section at 90ea384feb.
A save cleared its pending structural action before the action could
fail, so a retry no longer did what was asked. A new scoped node saved
without its scope lost the action name and failed again after the
scope was set. A node rejected for a parent in another tree became a
root on the next save. After an observer veto and the documented
rollback, saving a moved node again wrote only its new parent_id,
leaving the tree with a wrong parent.

callPendingAction() now keeps the whole pending action until it runs.
save() and saveOrIgnore() restore that action when their own save
returns false or throws, unless an observer queued a new one. A
successful save never touches the pending action, so a save repeated
from a created or updated observer does not replay it, and an action
an observer queues waits for the next save. The guide's transaction
section now covers saves that return false through saveOrIgnore() and
the retry.

Other fixes:

- Internal structural reloads read the base query on the write
  connection, so they no longer fire retrieved listeners, and still
  throw ModelNotFoundException for a missing row.
- Assigning parent_id through setAttribute() returns the model.
- rawNode() and setDepth() store a missing depth as 0 instead of
  writing null into the non-null depth column.
- The protected hooks use upstream's names again:
  callPendingAction() and assertNodeExists(), which keeps Hypervel's
  positive-bounds check.

Only null marks a root. Upstream also treats 0 and '' as root parent
IDs; here they stay real parent keys, so the README records the
difference and setParentId() notes it. Upstream's root tests are
ported. The scoped parent-before-scope tests use upstream's names and
order.

Ports Aimeos 9da1a8fb86 (direct dispatch), f3a0a5aa51 (setDepth()
default), e8a74e20fd (root tests only) and b5faf0167e (test names),
plus the creation and raw-node depth parts of #10, at Aimeos
90ea384feb.

Validation: NodeTest, NodeUuidTest, ScopedNodeTest and
ScopedNodeUuidTest pass on SQLite, MySQL 8.4, MariaDB 11 and
PostgreSQL 17. The saveOrIgnore() test runs only on SQLite and
PostgreSQL, whose grammars support it. Each database's Nested Set
directory and tests/NestedSet pass serially and under ParaTest.
php-cs-fixer reports no changes and composer analyse reports no
errors.
The package now includes tests ported from lunarphp/nestedset, whose
MIT license adds "Copyright (c) 2026 Neon Digital (maintenance for
Lunar)" to Alexander Kalnoy's notice. The MIT license requires that
notice in copies, so the Nested Set license now includes the Lunar line
between the Aimeos and Hypervel lines.

Source: Lunar LICENSE.md at 12419691f0.
…extra reload

getNodeData() passed its columns to first(), which ignores them once a
global scope has selected columns. A model whose global scope added
columns got that projection back instead of _lft, _rgt and depth, so
moving one of its nodes failed. Its column and key references were
also unqualified, so a self-join or a global scope joining another
table with an id column made the lookup ambiguous. getNodeData() now
calls select() with the model's qualified structural columns and
filters on the qualified key, keeping the query's visibility
constraints.

Other changes:

- Model::create() re-read every new node after inserting it. It now
  re-reads only when it created children, whose descendants widen the
  node after the last append refreshed it. Upstream dropped the reload
  entirely, which leaves stale bounds in that case; a test covers it.
- columnPatch() rendered a zero height or distance as "_lft"0, which
  is invalid SQL. It now renders "+ 0", as both forks do. The package's
  own callers skip zero offsets before reaching it.
- moveNode() gets back upstream's two comments.
- The README records that the query builder's getDepth($position) is
  depthForPosition($position), which returns the depth of a node
  inserted at that position rather than the enclosing node's depth.

Tests: Aimeos's six move tests replace the original package's
testCategoryMovesDown and testCategoryMovesUp, and Lunar's global-scope lookup, move and zero-offset tests are
ported. The subtree-depth test now checks persisted depth. The
database-only subtree-depth and savepoint-rollback tests and the
SQLite-only transaction-retry test move into the driver matrix, which
covers each on every database.

Ports Aimeos 3d866e0 (#5), 14374a7 (#6), the create() part of
a5c2d4f866 and the move tests from 28e6a4e066 at Aimeos 90ea384feb,
and the getNodeData() and columnPatch() parts of Lunar 7a81875 at
Lunar 12419691f0.

Validation: NodeTest, NodeUuidTest, ScopedNodeTest, ScopedNodeUuidTest
and NestedSetDatabaseTest pass on SQLite, MySQL 8.4, MariaDB 11 and
PostgreSQL 17 where applicable. Each database's Nested Set directory
and tests/NestedSet pass serially and under ParaTest. php-cs-fixer
reports no changes and composer analyse reports no errors.
…concile query scope tests

whereDescendantOf() with a key looked up the node's bounds through a
fresh model query. On a read/write split connection that lookup always
went to the replica, even when the outer query used useWritePdo(). It
now uses the builder's own lookup query, as depthForPosition() and
moveNode() already do, so the lookup follows the query's connection
choice with no extra query.

The README now states that the depth column is required and that
withDepth() reads the stored depth, so global scopes that hide
ancestors do not change a node's depth.

Upstream reconciliation (Aimeos master 90ea384feb, Lunar main
12419691f0):

- aimeos/laravel-nestedset#25 (c861196): ancestor and descendant
  constraints already accept a node or a key.
- 7fc60fcb00: the parent ID was already qualified. Its upstream test,
  testWhereIsRootQualifiesParentId, now holds the whereIsRoot(),
  withoutRoot() and hasParent() self-join assertions.
- 2dbf21a582: withDepth() always reads the stored column, so there is
  no schema check or computed fallback. The upstream test is ported.
- 3cbd0fa6e0: Hypervel's scalar position subqueries already select
  one row by primary key without global scopes; its test was already
  present.
- lunarphp/nestedset#3 (7a81875): QueryScopesTest's missing cases move
  into the shared node tests, so they run on all four databases. The
  previous-node boundary case now checks sony to galaxy; upstream's
  galaxy to samsung is the parent case testRetrievesPrevNode covers.
  Its computed-depth withDepth() cases do not apply to stored depth.

A test covers the remaining ancestor and descendant shortcut methods.
The integration database test drops its union ordering half, which the
driver matrix covers through testDefaultOrderClearsPreviousUnionOrderBindings.

Validation: NodeTest and NodeUuidTest on SQLite, MySQL 8.4, MariaDB 11
and PostgreSQL 17; scoped node tests, NestedSetDatabaseTest and
NestedSetReadWriteTest; tests/NestedSet serially and with ParaTest;
php-cs-fixer and composer analyse.
linkNodes() and toTree() cleared every node's parent and children
relations before linking. A node whose parent was not in the collection
lost an eager-loaded parent, so accessing it ran another query. Linking
now replaces children and in-collection parents only; roots still get a
null parent. A regression test covers a parent loaded with with('parent')
outside a descendant collection.

Reconcile the collection tests with Aimeos: port the leaf children,
collection-order and attribute-equality assertions, and rename the
serialization, deep flat tree and multiple-root tests to the upstream
names. The flat tree test now checks the complete key order.

Port the relation count hash refactor and the parent stub comment. Keep
serializing a linked node's parent stub like any loaded relation, as
Aimeos does since #23; the original package and Lunar hide parent from
serialization entirely.

Document that null, 0 and an empty string passed to toTree() or
toFlatTree() select nodes with that parent ID; Aimeos infers the root
for 0 and an empty string and rejects null.

Upstream: aimeos/laravel-nestedset master 90ea384feb (#8 6045762,
a67c93f, #23 f5c2c8b, 97b0a6e, 12d81c2, 3b906ef, ea791eb, cf37f7b,
dfa0681, 0b4388b).

Validation: NodeTest, NodeUuidTest, ScopedNodeTest and ScopedNodeUuidTest
on SQLite, MySQL 8.4, MariaDB 11 and PostgreSQL 17; tests/NestedSet
serially and with ParaTest; php-cs-fixer; composer analyse.
Eager loading ancestors, descendants or siblings for many parents was
slow or failed. Matching compared results with every parent: ancestors
for all nodes of an 11,111-node tree took 50 s, and descendants ordered
by name took over 58 s. Each parent added an "or" group, so the
all-node ancestor query took 28 s on MySQL, and SQLite rejected loads
of about 1,000 or more parents because it limits expressions to 1,000
levels.

Constraints:
- Ancestors use one group per scope: the reduced parents' bounds and a
  balanced CASE that finds the nearest parent right bound at or after a
  row's left bound.
- Descendant and sibling groups are nested in balanced "or" groups of
  at most 64. Parents without room for descendants are skipped, so a
  load of only leaves runs no query.
- Integer bounds are inlined, as whereIntegerInRaw() does.

Matching: matchMany() receives the persisted parents together, replacing
Aimeos's per-parent hooks. Ancestors use one stack sweep per scope,
descendants a binary search over sorted buckets, and siblings their
scope and parent buckets, shared by siblingsAndSelf parents. A single
parent uses a linear filter. Custom result order is restored only when
results were re-sorted. Constraint hooks receive the base query builder.

On the same tree, all-node ancestor matching takes 77 ms, name-ordered
descendant matching 134 ms, and the MySQL ancestor query 176 ms. Loads
of more than 1,000 parents or scopes pass on SQLite.

getAncestors(), getDescendants() and getSiblings() keep running fresh
queries instead of returning or replacing loaded relations (Aimeos
30c56b4); a test covers loaded relations staying unchanged. The README
records this and the hook changes.

Port the upstream relation tests under their names, folding in
Hypervel's duplicates, and add tests for leaf-only descendant loads,
more than 1,000 parent intervals and scopes, and fresh get*() queries.

Upstream: aimeos/laravel-nestedset master 90ea384feb (#3 3b14873,
#4 1d34707, #15 f7e4a72, #22 c2ed517, 0121850, 30c56b4, 3588a2a,
7b19f85, a06877c, 32f5f32, 0d3603a, 1685638, 92e64e7, 5087f54, and the
eager order test from 3e4f690); lazychaser/laravel-nestedset v7
4e9ad66a3c (#606 10ac72d).

Validation: NodeTest, NodeUuidTest, ScopedNodeTest and ScopedNodeUuidTest
on SQLite, MySQL 8.4, MariaDB 11 and PostgreSQL 17; tests/NestedSet
serially and with ParaTest; php-cs-fixer; composer analyse.
ApplicationTest's default-migrations cases left
TESTBENCH_WITHOUT_DEFAULT_MIGRATIONS in $_SERVER and $_ENV, so later
tests in the same worker dropped the default migration path. In default
order, LoadMigrationsFromArrayTest::itCanRegisterMigrations failed; in
reverse order, the default-migrations case itself failed. A full
parallel run failed the same way once.

Testbench writes the configured environment through the Env
repository's immutable writer, which records the keys it loaded. While
restoring the suite's masked APP_ENV, createApplication() then calls
Env::flushRepository(). The test's Env::forget() cleared through a new
writer, which treats the value as externally defined and keeps it.

Wrap the case in withEnvironmentValue(), which restores getenv(),
$_SERVER and $_ENV even when application creation throws.

Validation: tests/Testbench/Foundation in default and reverse order;
composer test:testbench; ParaTest over tests/Testbench; php-cs-fixer.
…llow it

Hard deletes removed the node's row before its descendants, so a
restricting foreign key on parent_id rejected every subtree delete.
Tree upkeep ran in five boot listeners, and a custom $dispatchesEvents
listener that returned a value skipped them: a save could leave null
bounds, and a delete could leave descendants with crossing intervals.
Soft deletes and restores skipped descendants hidden by ordinary global
scopes, and forceDestroy() of a node together with its descendant threw
ModelNotFoundException.

fireModelEvent() now owns the upkeep for saving, deleting, deleted,
restoring and restored, keeping the parent's handling of quiet
operations, $halt and listener results:
- deleting reloads the node's bounds, then notifies observers. Once
  they allow a hard delete, descendants are removed in descending _lft
  order before the node's own row, because MySQL and MariaDB check a
  restricting parent key per row. A veto leaves the subtree unchanged.
  A row already removed by an ancestor's delete returns false, so
  destroy() counts only the rows it deleted.
- deleted closes the gap after hard deletes and trashes descendants
  after soft deletes.
- Cascades and restores use the scope-free nested set query.

With shouldFireDescendantEvents(), restoring also runs through
descendant model events, parents first, in the same bounded chunks as
deletion (children first). getDescendantDeleteChunkSize() becomes
getDescendantChunkSize(), a short chunk ends the loop, and a vetoed
descendant throws. Transactions stay with the caller: deleteOrFail() or
DB::transaction() roll back a partial failure.

SoftDeletes::forceDelete() now resets $forceDeleting when delete()
throws, so a later delete() on the same model stays a soft delete.

Restore keeps the stored deletion time as its threshold, so descendants
deleted in the same second need no startOfSecond() adjustment. Aimeos's
skipped reload for soft deletes is not ported: the soft cascade selects
descendants by the node's bounds.

Tests cover a restricting parent key on every database (NodeTest now
enables SQLite foreign keys), a deleting veto registered after boot,
rollback after partial failures, hidden descendants, custom event
results, destroy() across a node and its descendant, evented restore
and its veto, and the timestamp boundary. Upstream's chunked delete test
is ported with an exact query count. The README records the opt-in
events, the renamed hook and the delete timing; the guide covers parent
keys, evented restore and transactions.

Upstream: aimeos/laravel-nestedset master 90ea384feb (#13 a681d9c,
#21 82044df, #24 fecfae3, 0a48675, bfcaaa8, a68b411, 4a25401, a0f3ea1,
0d0502d).

Validation: NodeTest, NodeUuidTest, ScopedNodeTest, ScopedNodeUuidTest
and NestedSetDatabaseTest on SQLite, MySQL 8.4, MariaDB 11 and
PostgreSQL 17; tests/NestedSet serially and with ParaTest;
DatabaseEloquentSoftDeletesIntegrationTest; php-cs-fixer; composer
analyse.
fixTree() and fixSubtree() hydrated every node they selected. Repairing
a healthy 111,111-node tree took 2.7 s and 367.5 MB on MySQL and fired
a retrieved event per node. countErrors(), getTotalErrors() and
isBroken() built fresh queries, so they ignored the caller's
useWritePdo(). rebuildTree(delete: true) deleted removed nodes in no set
order, and MySQL and MariaDB rejected deleting a parent before its
children under a restricting foreign key on parent_id.

Repair now reads plain rows with the same columns, on the write
connection and in default order, and records each node's placement in
one traversal. Rows whose bounds, parent and depth already match are
left alone. The others are hydrated through the model, assigned and
saved, so saving and saved observers and extraColumns still apply. The
healthy tree now takes 358 ms and 73.2 MB with no retrieved events. A
damaged 11,111-node tree with 11,110 saves drops from 9.5 s and 60.8 MB
to 7.0 s and 7.4 MB on MySQL, and from 6.4 s to 5.0 s on PostgreSQL.

When a subtree grows, rows shifted by the gap update are compared with
their shifted values, so rebuild no longer saves unchanged children a
second time. The returned count still adds the gap update's rows to the
repaired nodes. fixSubtree() accepts null, as Aimeos's does.

Diagnostics share the caller's useWritePdo() and keep Hypervel's SQL
checks. Aimeos now counts errors with a PHP row scan; on the
111,111-node tree it takes 1.25 s on MySQL, MariaDB and PostgreSQL and
4.1 s on SQLite, using 234 MB. The SQL checks take 1.2 s, 0.86 s,
0.45 s and 0.38 s respectively, using 0.5 MB.

Rebuild deletes removed nodes in descending _lft order.

The README records the error-count differences from Aimeos and that
repair hydrates only the nodes it saves.

Upstream's diagnostics, repair and rebuild tests are ported under their
names with Hypervel's error keys, folding in overlapping Hypervel tests.
Lunar's global-scope cases are folded into the countErrors() and
fixTree() global-scope tests; its scope-removal callbacks are not
ported, because diagnostics and repair already use the scope-free
nested set query. New tests cover diagnostics on the writer, rebuild
deletion under a restricting parent key, branching parent buckets,
nested rebuild payloads, soft-deleted rebuild removals, the events
repair fires, saves and counts when a subtree grows, and scoped orphan
repair. The integration case's composite diagnostics and UUID repair
tests move into the scoped matrix.

Upstream: aimeos/laravel-nestedset master 90ea384feb (#7 0ce31f3,
e81300e, 449da3c, 8e6198c, 9c6fb87, 2b591d4, c9a58b2, b09ef00, 0ff8d44,
4e03763, bbf2c6c, a6c59fd, 3e4f690); lunarphp/nestedset main 12419691f0
(#3 3393853).

Validation: NodeTest, NodeUuidTest, ScopedNodeTest, ScopedNodeUuidTest
and NestedSetDatabaseTest on SQLite, MySQL 8.4, MariaDB 11 and
PostgreSQL 17; tests/NestedSet with ParaTest; php-cs-fixer; composer
analyse.
Lunar's root scoped node tests match the original package's
ScopedNodeTest, and each of those tests already exists in the scoped
test base under its upstream name. Upstream keeps testRebuildsTree
commented out with no assertions, and Aimeos dropped it.

- testRebuildsTree rebuilds one menu with delete enabled. It checks that
  only that menu's other nodes are deleted, and that a menu_id in the
  payload does not move the new child into another menu.
- A root created in an empty scope starts its own tree at [1, 2].
- The soft-delete restore test now checks, right after the delete, that
  the other menu's node inside the deleted bounds stays active. The
  final restore assertions alone would not catch an unscoped cascade.
- The separate integration test case and its MySQL, MariaDB, Postgres
  and SQLite wrappers are removed. The node, UUID and scoped test
  matrix covers its integer and UUID trees, depth, scope isolation and
  soft-delete restore on every driver.
- ensureConcreteNestedSetScope() drops its model parameter, which no
  caller passed.
- The README records the getScopeAttributes() return type and
  ensureSameTree() differences from Aimeos.

Upstream: lunarphp/nestedset main 12419691f0 (root 60101d3d),
lazychaser/laravel-nestedset v7 4e9ad66a3c.

Validation: NodeTest, NodeUuidTest, ScopedNodeTest, ScopedNodeUuidTest
and NestedSetSchemaTest on SQLite, MySQL 8.4, MariaDB 11 and
PostgreSQL 17; ParaTest tests/NestedSet; php-cs-fixer; composer analyse.
…lain rows

rebuildTree() created each new node with newInstance($scopeAttributes),
which mass assigns. A scope attribute missing from $fillable was dropped
or rejected, so the new node could not join the rebuilt tree. The scope
is now set as raw attributes over the fresh model's attributes, so model
defaults remain and the rebuilt tree's scope takes precedence. Aimeos,
the original package and Lunar mass assign new rebuild nodes the same
way.

depthForPosition() read the depth through the Eloquent value(), which
hydrates a model and fires retrieved listeners. It now reads through the
base query. Aimeos's getDepth() hydrates the same way.

Tests cover rebuilding a scope whose scope attribute is guarded and has
a model default, and a low-level move that derives its depth without
firing retrieved.

Upstream: aimeos/laravel-nestedset master 90ea384feb,
lazychaser/laravel-nestedset v7 4e9ad66a3c, lunarphp/nestedset main
12419691f0.

Validation: NodeTest, NodeUuidTest, ScopedNodeTest, ScopedNodeUuidTest
and NestedSetSchemaTest on SQLite, MySQL 8.4, MariaDB 11 and
PostgreSQL 17; ParaTest tests/NestedSet; php-cs-fixer; composer
analyse.
Applications analyzed with PHPStan could not see Nested Set builder
methods: HasNode did not bind its builder through HasBuilder, so calls
such as Category::whereIsRoot() and Category::query()->root() were
undefined, and relations and collections lost their model type.

- QueryBuilder is generic over its model (aimeos/laravel-nestedset #9).
  Its collection queries return Collection<int, TModel>, and root()
  returns the model or null.
- HasNode binds HasBuilder<QueryBuilder<static>>, so query(), static
  calls and custom builders keep the model. Its relation, builder and
  getter methods return typed relations, builders and collections.
- BaseRelation and the ancestor, descendant and sibling relations relate
  a node to its own class and return the nested set collection.
- The collection is generic. toTree() and toFlatTree() return
  integer-keyed collections. flattenTree() builds a node list that
  toFlatTree() wraps once, as toTree() does, instead of pushing each
  node onto the result.
- types/NestedSet covers builders, static forwarding, a subclass,
  relations, collections, nullable getters, a custom builder and
  flattening a string-keyed collection.

DescendantsRelation now notes why eager constraints keep per-parent
ranges, and that SQLite still prepares thousands of disjoint ranges in
quadratic time. The PHPStan config comment names HasNode.

Upstream: aimeos/laravel-nestedset #9, reconciled at 90ea384feb.

Validation: composer analyse (both configurations), php-cs-fixer, and
ParaTest tests/NestedSet (811 tests).
Aimeos master is the only tracked Nested Set upstream. The original
lazychaser/laravel-nestedset and lunarphp/nestedset are not tracked,
because Aimeos carries their applicable fixes.

The notes map upstream source, tests and README onto Hypervel's layout,
keep the grouped method order (find upstream changes by method name),
and direct ported cases into the shared test bases that also run on
MySQL, MariaDB and PostgreSQL. Checkpoint fields stay unset until the
reconciliation is complete.
PHPStan reported the builder macros registered by SoftDeletingScope and
Scout's SearchableScope as undefined on builders and relations:
withTrashed(), withoutTrashed(), onlyTrashed(), restore(),
restoreOrCreate(), createOrRestore(), searchable() and unsearchable().
Static model calls fell back to the SoftDeletes @method tags, which
return Builder<static> and drop a custom builder.

- BuilderMacroResolver maps a model trait to an interface declaring its
  macros through the eloquentBuilderMacros parameter. The database
  extension maps SoftDeletes to SoftDeletingMacros. Scout's new
  extension.neon maps Searchable to SearchableMacros; it is listed in
  Scout's extra.phpstan.includes and in both components configurations.
- Traits are found through parent classes and nested traits. Names
  match exactly, as Builder::hasMacro() does, so the extensions' method
  caches now keep the exact spelling.
- Fluent macros return the builder or relation they run on, including
  custom builders. The others keep their declared returns: int, the
  model, or void.
- Macros take precedence over same-named scopes, as in
  Builder::__call(), for builders, relations, static model calls and
  the named scope extension. Native builder methods still win.
- SoftDeletes keeps Laravel's @method tags: registered extensions run
  before PHPDoc annotations, so the tags no longer decide the type.
- The database and installation docs mention the soft deleting methods
  and the Scout extension.
- types cover plain and custom builders, inherited and nested traits,
  relations including HasManyThrough, a legacy scope sharing a macro
  name, case sensitivity, a model without the trait and a soft-deleting
  nested set node.

Validation: composer analyse (both configurations) on PHPStan 2.3.0,
and the types configuration on 2.2.16; php-cs-fixer;
tests/Database/PHPStan, Scout PackageMetadataTest and ComposerFileTest;
composer validate for src/scout. Case-insensitive cache keys, scopes
before macros and the previous model forwarding condition each fail
the type fixtures.
The components analysis allowed PHPStan ^2.2.15, but 2.2.16 reports
"Variable $actualMessage might not be defined" in
InteractsWithExceptionHandling::assertThrows(). The variable is set
in the catch block, and Assert::assertTrue($thrown) stops the method
unless that block ran. PHPStan 2.3 follows this, so the code stays as
it is and nothing is suppressed.

The root development requirement (through Composer) and the database
and foundation split packages' require-dev now use ^2.3. Foundation
contains the reported code; database ships the PHPStan extension.
This is a development tool minimum only: the types configuration,
which loads the extensions, also passes on PHPStan 2.2.16.

Validation: composer analyse (both configurations) on PHPStan 2.3.0;
PackageManifestConsistencyTest, Database and Foundation
PackageMetadataTest; composer validate for src/database and
src/foundation.
The guide now covers what the Aimeos, original and Lunar READMEs
document and Hypervel supports, checked against the current source. New
sections cover adding nested set columns to an existing table, custom
column names, deleting nodes, and transactions and concurrency. Other
sections add relationship-based creation, neighbor moves, ancestor
order, related-model subqueries, stored depth, flat trees, scoped key
lookups and the soft-deleted leaf rule. A warning covers SQLite's slow
query preparation when eager loading descendants for thousands of
parents.

Upstream advice that does not hold for Hypervel was not carried over:
whereDescendantAndSelf() exists in no upstream source, eager loading
needs no scoped query because constraints are grouped by each parent's
scope, and the lock example waits with block() instead of skipping the
write. The transaction examples throw when a structural save returns
false, because the save has already updated other rows' bounds.

The README names the HasNode trait in place of upstream's NodeTrait and
drops an entry describing a correctness fix and a sentence describing
a performance change, neither of which is a public difference.
prevNodes()'s docblock no longer claims a reversed order.

The custom-parent fixture becomes a custom-column table (lft, rgt,
level, ancestor_id), with a test that creates, moves, deletes,
diagnoses and repairs a tree through those columns.

Validation: php-cs-fixer, composer analyse, NodeTest on SQLite, MySQL,
MariaDB and PostgreSQL, and ParaTest tests/NestedSet.
Aimeos master is reconciled through 90ea384feb, including the source,
tests and README, with pull request 25 as the last one examined.
tests/Testbench/Foundation/ApplicationTest.php takes 0.4's version.
3c6fb52 fixes the environment leak in Testbench itself, so
Env::forget() clears the default-migrations value again, and this
branch's withEnvironmentValue() wrapper from 5a1293f is no longer
needed.
@coderabbitai

coderabbitai Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Repository: hypervel/components-backup/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 98207597-d368-4583-9210-fb7fb4344084

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Nested Set: fix tree-consistency bugs, scale eager loading/repair, add generics

🐞 Bug fix ✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Fixes multiple tree-consistency bugs: lost pending save actions after failed/vetoed saves,
 premature row deletion breaking FK constraints, unqualified column lookups, zero-offset SQL, null
 depth writes, and replica reads during mutations.
• Rewrites eager loading of ancestors/descendants/siblings to use balanced constraint groups and
 batched matching instead of per-parent queries, cutting multi-second loads to milliseconds on large
 trees.
• Makes fixTree()/fixSubtree() read plain rows and hydrate/save only changed nodes, and adds a
 (_lft, _rgt) index, dramatically reducing repair time and memory on large trees.
• Adds PHPStan generics across the nested-set builder, relations and collection, plus a macro
 resolver so SoftDeletes/Scout builder macros are understood by static analysis.
• Fixes SoftDeletes::forceDelete() to reset its flag in a finally block so a failed force delete
 doesn't corrupt later deletes.
• Expands node/scoped-node test suites to run against MySQL, MariaDB, PostgreSQL and SQLite with
 integer and UUID keys on prefixed connections, and overhauls docs/README to document behavioral
 differences from upstream.
Diagram

graph TD
  Model["HasNode trait"] --> QB["QueryBuilder"] --> DB[(Database)]
  Model --> FireEvent["fireModelEvent()"] --> Upkeep["Save/Delete/Restore upkeep"]
  QB --> Relations["Ancestors/Descendants/Siblings Relation"] --> MatchMany["matchMany() batched matching"]
  QB --> Repair["fixTree()/fixSubtree()"] --> DB
  PHPStanExt["BuilderMacroResolver"] --> SoftDeletes["SoftDeletingMacros"]
  PHPStanExt --> Scout["SearchableMacros"]
  subgraph Legend
    direction LR
    _mod([Module]) ~~~ _db[(Database)] ~~~ _ext{{External}}
  end
Loading
High-Level Assessment

The PR is a correctness/performance hardening pass on an existing in-house fork of laravel-nestedset, reconciling it against upstream Aimeos, the original package, and Lunar's fork. Given it must stay API-compatible with existing consumers and match specific behavioral guarantees (null-only roots, stored depth, typed schema), re-architecting rather than incrementally patching and backporting fixes would be riskier and provide no benefit; the chosen approach (systematic audit + targeted fixes + expanded cross-database tests) is appropriate.

Files changed (71) +8372 / -5163

Enhancement (12) +668 / -278
BaseRelation.phpReplace per-parent eager loading hooks with batched matchMany() and balanced OR groups +129/-67

Replace per-parent eager loading hooks with batched matchMany() and balanced OR groups

• Introduces constrainEagerModels()/matchMany() abstractions, bucket sorting, and bounded balanced OR-group constraint building to make eager loading scale to thousands of parents.

src/nested-set/src/Eloquent/BaseRelation.php

AncestorsRelation.phpRewrite ancestor eager loading with balanced CASE expression and stack-sweep matching +161/-28

Rewrite ancestor eager loading with balanced CASE expression and stack-sweep matching

• Replaces per-parent OR conditions with a single balanced CASE-based constraint per scope and matches ancestors via a left-bound sweep instead of O(n*m) comparisons.

src/nested-set/src/Eloquent/AncestorsRelation.php

DescendantsRelation.phpScale descendant eager loading with balanced OR groups and binary search matching +91/-89

Scale descendant eager loading with balanced OR groups and binary search matching

• Groups eager constraints into bounded OR chunks and matches descendants via binary search over sorted buckets instead of per-parent comparisons.

src/nested-set/src/Eloquent/DescendantsRelation.php

SiblingsRelation.phpBatch sibling eager constraints and matching +48/-63

Batch sibling eager constraints and matching

• Moves sibling eager constraint building and matching onto the shared balanced-OR and matchMany() infrastructure.

src/nested-set/src/Eloquent/SiblingsRelation.php

Collection.phpAdd generics and stop clearing externally eager-loaded parent relations +31/-14

Add generics and stop clearing externally eager-loaded parent relations

• Adds template annotations and changes linkNodes()/toTree() to preserve parent relations loaded from outside the collection.

src/nested-set/src/Eloquent/Collection.php

NestedSet.phpAdd (_lft, _rgt) composite index and update dropNestedSet() +5/-2

Add (_lft, _rgt) composite index and update dropNestedSet()

• Schema helpers now create a combined left/right bound index so ancestor queries check both bounds within the index, and dropNestedSet() drops the same set.

src/nested-set/src/NestedSet.php

BuilderMacroResolver.phpAdd resolver mapping model traits to builder macro interfaces +99/-0

Add resolver mapping model traits to builder macro interfaces

• New PHPStan helper that maps a model trait to an interface declaring its macros via the eloquentBuilderMacros parameter, resolving fluent macro return types.

src/database/src/PHPStan/BuilderMacroResolver.php

ForwardedBuilderMethodExtension.phpResolve builder macros before named scopes +12/-6

Resolve builder macros before named scopes

• Wires BuilderMacroResolver into builder/relation method resolution so trait-added macros take precedence over same-named scopes.

src/database/src/PHPStan/ForwardedBuilderMethodExtension.php

ForwardedModelMethodExtension.phpLet builder macros win over named scopes on static model calls +7/-2

Let builder macros win over named scopes on static model calls

• Adds macro-awareness to static model method resolution and makes macro-name cache keys case-sensitive.

src/database/src/PHPStan/ForwardedModelMethodExtension.php

NamedScopeMethodExtension.phpGive builder macros precedence over named scopes +7/-7

Give builder macros precedence over named scopes

• Named scope resolution now defers to a trait's builder macro when one exists with the same name.

src/database/src/PHPStan/NamedScopeMethodExtension.php

SoftDeletingMacros.phpDeclare SoftDeletingScope's builder macros for PHPStan +55/-0

Declare SoftDeletingScope's builder macros for PHPStan

• New generic interface declaring withTrashed(), onlyTrashed(), restore() and related macros for static analysis.

src/database/src/PHPStan/SoftDeletingMacros.php

SearchableMacros.phpDeclare SearchableScope's builder macros for PHPStan +23/-0

Declare SearchableScope's builder macros for PHPStan

• New interface declaring searchable() and unsearchable() macro signatures.

src/scout/src/PHPStan/SearchableMacros.php

Bug fix (3) +472 / -237
HasNode.phpCentralize tree upkeep in fireModelEvent() and fix save/delete lifecycle bugs +263/-119

Centralize tree upkeep in fireModelEvent() and fix save/delete lifecycle bugs

• Moves tree maintenance from five boot listeners into an overridden fireModelEvent(), preserving pending actions across failed/vetoed saves, reordering deletes to remove descendants before the ancestor row, and renaming/clarifying several internal helpers.

src/nested-set/src/HasNode.php

QueryBuilder.phpFix getNodeData/columnPatch/depth bugs and scale fixTree() repair +199/-113

Fix getNodeData/columnPatch/depth bugs and scale fixTree() repair

• Qualifies columns in getNodeData(), fixes zero-offset SQL rendering, avoids hydrating every row during repair, and reworks fixTree()/fixSubtree() to compare and save only changed rows.

src/nested-set/src/Eloquent/QueryBuilder.php

SoftDeletes.phpReset forceDeleting flag in a finally block +10/-5

Reset forceDeleting flag in a finally block

• SoftDeletes::forceDelete() now resets $forceDeleting in a finally block so a thrown delete doesn't leave the model stuck in force-delete mode.

src/database/src/Eloquent/SoftDeletes.php

Tests (41) +6878 / -4590
SoftDeletes.phpAdd PHPStan type fixture for SoftDeletes builder macros +119/-0

Add PHPStan type fixture for SoftDeletes builder macros

• New fixture asserting resolved types for withTrashed(), restore(), and related macros across plain and custom-builder models.

types/Database/Eloquent/SoftDeletes.php

NestedSet.phpAdd PHPStan type fixture for nested-set generics +80/-0

Add PHPStan type fixture for nested-set generics

• New fixture asserting generic types resolve correctly for QueryBuilder, relations, and Collection across custom builders and subclasses.

types/NestedSet/NestedSet.php

Searchable.phpAdd PHPStan type fixture for Scout macros +56/-0

Add PHPStan type fixture for Scout macros

• New fixture asserting searchable()/unsearchable() macro types on builders and relations.

types/Scout/Searchable.php

DatabaseEloquentSoftDeletesIntegrationTest.phpAdd regression test for forceDelete() finally-block fix +1/-0

Add regression test for forceDelete() finally-block fix

• Adds a test covering the new behavior where forceDelete() resets its flag even when the delete throws.

tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php

ForwardedBuilderMethodExtensionTest.phpAdd test coverage for builder macro extension changes +8/-1

Add test coverage for builder macro extension changes

• Extends existing PHPStan extension tests to cover the new macro resolver interaction.

tests/Database/PHPStan/ForwardedBuilderMethodExtensionTest.php

NodeTestBase.phpIntroduce shared NodeTestBase for integer and UUID key node tests +4329/-0

Introduce shared NodeTestBase for integer and UUID key node tests

• New abstract base extracting the shared node test cases, parameterized by key type, used by NodeTest and NodeUuidTest and their per-database subclasses.

tests/NestedSet/NodeTestBase.php

NodeTest.phpRebase NodeTest on NodeTestBase and trim to integer-only cases +529/-3218

Rebase NodeTest on NodeTestBase and trim to integer-only cases

• Converts NodeTest into a thin subclass of NodeTestBase, moving shared logic out and keeping only integer-key-specific cases.

tests/NestedSet/NodeTest.php

NodeUuidTest.phpAdd UUID-key node test subclass +30/-0

Add UUID-key node test subclass

• New test class running the shared node test suite against UUID primary and parent keys.

tests/NestedSet/NodeUuidTest.php

ScopedNodeTestBase.phpIntroduce shared ScopedNodeTestBase for integer and UUID key scoped tests +1066/-0

Introduce shared ScopedNodeTestBase for integer and UUID key scoped tests

• New abstract base extracting shared scoped-node test cases for reuse across key types and databases.

tests/NestedSet/ScopedNodeTestBase.php

ScopedNodeTest.phpRebase ScopedNodeTest on ScopedNodeTestBase +36/-844

Rebase ScopedNodeTest on ScopedNodeTestBase

• Converts ScopedNodeTest into a thin subclass, moving shared setup/fixtures to the base class.

tests/NestedSet/ScopedNodeTest.php

ScopedNodeUuidTest.phpAdd UUID-key scoped node test subclass +30/-0

Add UUID-key scoped node test subclass

• New test class running the shared scoped node suite against UUID keys.

tests/NestedSet/ScopedNodeUuidTest.php

NestedSetCacheTest.phpAdd tests for per-class soft-delete and node-trait caching +48/-0

Add tests for per-class soft-delete and node-trait caching

• New test verifying usesSoftDelete() and NestedSet::isNode() caches are keyed and flushed per model class.

tests/NestedSet/NestedSetCacheTest.php

NestedSetMutationLifecycleTest.phpRemove obsolete mutation lifecycle test file +0/-26

Remove obsolete mutation lifecycle test file

• Deletes a test file whose coverage was superseded by the new fireModelEvent()-based lifecycle tests.

tests/NestedSet/NestedSetMutationLifecycleTest.php

NestedSetReadWriteTest.phpAdd read/write-connection coverage for whereDescendantOf and diagnostics +52/-0

Add read/write-connection coverage for whereDescendantOf and diagnostics

• Adds assertions that whereDescendantOf() and countErrors()/isBroken() honor useWritePdo() correctly.

tests/NestedSet/NestedSetReadWriteTest.php

NestedSetSchemaTest.phpRework schema test to check exact columns, types and indexes under a prefix +59/-36

Rework schema test to check exact columns, types and indexes under a prefix

• Replaces loose column/index assertions with exact checks on column types and index sets, run against a prefixed connection.

tests/NestedSet/NestedSetSchemaTest.php

NestedSetTest.phpUpdate unit tests for renamed/relocated nested-set internals +82/-49

Update unit tests for renamed/relocated nested-set internals

• Adjusts existing unit tests to match renamed methods and refactored eager-loading/repair internals.

tests/NestedSet/NestedSetTest.php

CategoryUuid.phpAdd UUID-key Category fixture model +14/-0

Add UUID-key Category fixture model

• New fixture model using UUID primary and parent keys for the UUID test suite.

tests/NestedSet/Fixtures/Models/CategoryUuid.php

MenuItemUuid.phpAdd UUID-key MenuItem fixture model +14/-0

Add UUID-key MenuItem fixture model

• New fixture model for scoped UUID-key tests.

tests/NestedSet/Fixtures/Models/MenuItemUuid.php

2025_07_04_000000_create_custom_column_categories_table.phpAdd fixture migration for custom nested-set column names +32/-0

Add fixture migration for custom nested-set column names

• New migration exercising renamed lft/rgt/depth/parent columns for the custom-column fixture.

tests/NestedSet/Fixtures/migrations/2025_07_04_000000_create_custom_column_categories_table.php

2025_07_05_000000_create_nullable_menu_items_table.phpAdd fixture migration with nullable menu item columns +31/-0

Add fixture migration with nullable menu item columns

• New migration supporting a nullable-column scoped-node test fixture.

tests/NestedSet/Fixtures/migrations/2025_07_05_000000_create_nullable_menu_items_table.php

2025_07_06_000000_create_constrained_categories_table.phpAdd fixture migration with restricting foreign key on parent_id +37/-0

Add fixture migration with restricting foreign key on parent_id

• New migration used to verify hard-delete ordering satisfies restricting foreign keys.

tests/NestedSet/Fixtures/migrations/2025_07_06_000000_create_constrained_categories_table.php

2025_07_02_000000_create_categories_table.phpAdd UUID categories fixture migration +31/-0

Add UUID categories fixture migration

• New migration for the UUID-key category fixture schema.

tests/NestedSet/Fixtures/migrations/uuid/2025_07_02_000000_create_categories_table.php

2025_07_03_000000_create_menu_items_table.phpAdd UUID menu items fixture migration +31/-0

Add UUID menu items fixture migration

• New migration for the UUID-key scoped menu item fixture schema.

tests/NestedSet/Fixtures/migrations/uuid/2025_07_03_000000_create_menu_items_table.php

2025_07_03_000000_create_menu_items_table.phpMinor fixture migration update +1/-0

Minor fixture migration update

• Small adjustment to the existing menu items fixture migration.

tests/NestedSet/Fixtures/migrations/2025_07_03_000000_create_menu_items_table.php

NestedSetDatabaseTestCase.phpRemove obsolete shared integration test base +0/-397

Remove obsolete shared integration test base

• Deletes the old shared test base, replaced by per-key-type bases (NodeTestBase/ScopedNodeTestBase) and thin per-database subclasses.

tests/Integration/NestedSet/Database/NestedSetDatabaseTestCase.php

NestedSetDatabaseTest.phpRemove obsolete SQLite integration test +0/-13

Remove obsolete SQLite integration test

• Deletes the old SQLite-specific integration test superseded by the new test layout.

tests/Integration/NestedSet/Database/Sqlite/NestedSetDatabaseTest.php

NodeTest.phpAdd MySQL node test subclass +13/-0

Add MySQL node test subclass

• Thin subclass running the integer-key node suite against MySQL.

tests/Integration/NestedSet/Database/MySql/NodeTest.php

NodeUuidTest.phpAdd MySQL UUID-key node test subclass +13/-0

Add MySQL UUID-key node test subclass

• Thin subclass running the UUID-key node suite against MySQL.

tests/Integration/NestedSet/Database/MySql/NodeUuidTest.php

ScopedNodeTest.phpAdd MySQL scoped node test subclass +13/-0

Add MySQL scoped node test subclass

• Thin subclass running the scoped node suite against MySQL.

tests/Integration/NestedSet/Database/MySql/ScopedNodeTest.php

ScopedNodeUuidTest.phpAdd MySQL UUID scoped node test subclass +13/-0

Add MySQL UUID scoped node test subclass

• Thin subclass running the UUID-key scoped node suite against MySQL.

tests/Integration/NestedSet/Database/MySql/ScopedNodeUuidTest.php

NestedSetSchemaTest.phpUpdate MySQL schema test for new index/type assertions +2/-2

Update MySQL schema test for new index/type assertions

• Adjusts the MySQL schema integration test to match the reworked exact-schema assertions.

tests/Integration/NestedSet/Database/MySql/NestedSetSchemaTest.php

NodeTest.phpAdd MariaDB node test subclass +13/-0

Add MariaDB node test subclass

• Thin subclass running the integer-key node suite against MariaDB.

tests/Integration/NestedSet/Database/MariaDb/NodeTest.php

NodeUuidTest.phpAdd MariaDB UUID-key node test subclass +13/-0

Add MariaDB UUID-key node test subclass

• Thin subclass running the UUID-key node suite against MariaDB.

tests/Integration/NestedSet/Database/MariaDb/NodeUuidTest.php

ScopedNodeTest.phpAdd MariaDB scoped node test subclass +13/-0

Add MariaDB scoped node test subclass

• Thin subclass running the scoped node suite against MariaDB.

tests/Integration/NestedSet/Database/MariaDb/ScopedNodeTest.php

ScopedNodeUuidTest.phpAdd MariaDB UUID scoped node test subclass +13/-0

Add MariaDB UUID scoped node test subclass

• Thin subclass running the UUID-key scoped node suite against MariaDB.

tests/Integration/NestedSet/Database/MariaDb/ScopedNodeUuidTest.php

NestedSetSchemaTest.phpUpdate MariaDB schema test for new index/type assertions +2/-2

Update MariaDB schema test for new index/type assertions

• Adjusts the MariaDB schema integration test to match the reworked exact-schema assertions.

tests/Integration/NestedSet/Database/MariaDb/NestedSetSchemaTest.php

NodeTest.phpAdd PostgreSQL node test subclass +13/-0

Add PostgreSQL node test subclass

• Thin subclass running the integer-key node suite against PostgreSQL.

tests/Integration/NestedSet/Database/Postgres/NodeTest.php

NodeUuidTest.phpAdd PostgreSQL UUID-key node test subclass +13/-0

Add PostgreSQL UUID-key node test subclass

• Thin subclass running the UUID-key node suite against PostgreSQL.

tests/Integration/NestedSet/Database/Postgres/NodeUuidTest.php

ScopedNodeTest.phpAdd PostgreSQL scoped node test subclass +13/-0

Add PostgreSQL scoped node test subclass

• Thin subclass running the scoped node suite against PostgreSQL.

tests/Integration/NestedSet/Database/Postgres/ScopedNodeTest.php

ScopedNodeUuidTest.phpAdd PostgreSQL UUID scoped node test subclass +13/-0

Add PostgreSQL UUID scoped node test subclass

• Thin subclass running the UUID-key scoped node suite against PostgreSQL.

tests/Integration/NestedSet/Database/Postgres/ScopedNodeUuidTest.php

NestedSetSchemaTest.phpUpdate PostgreSQL schema test for new index/type assertions +2/-2

Update PostgreSQL schema test for new index/type assertions

• Adjusts the PostgreSQL schema integration test to match the reworked exact-schema assertions.

tests/Integration/NestedSet/Database/Postgres/NestedSetSchemaTest.php

Documentation (7) +327 / -54
NestedSetServiceProvider.phpDocument nestedSet() Blueprint macro behavior +2/-0

Document nestedSet() Blueprint macro behavior

• Adds a comment clarifying that the macro includes depth column and indexes, replacing upstream's separate macros.

src/nested-set/src/NestedSetServiceProvider.php

sync.yamlTrack Aimeos laravel-nestedset as the nested-set upstream +4/-3

Track Aimeos laravel-nestedset as the nested-set upstream

• Records the synced commit, PR, date and mapping notes for tracking future upstream changes.

docs/upstream-sync/sync.yaml

database.mdMinor documentation wording update +1/-1

Minor documentation wording update

• Small documentation correction related to the SoftDeletes/macro changes.

src/docs/database.md

installation.mdMinor documentation update +3/-1

Minor documentation update

• Small installation doc tweak.

src/docs/installation.md

nested-set.mdExpand nested-set guide with new sections and clarified semantics +287/-47

Expand nested-set guide with new sections and clarified semantics

• Adds documentation for adding nested-set columns to existing tables, custom column names, deleting nodes, transactions/concurrency, and several other new topics.

src/docs/nested-set.md

LICENSE.mdCredit Aimeos and Lunar maintainers +4/-0

Credit Aimeos and Lunar maintainers

• Adds license credit lines for the upstream projects this package reconciles against.

src/nested-set/LICENSE.md

README.mdDocument deliberate differences from upstream laravel-nestedset +26/-2

Document deliberate differences from upstream laravel-nestedset

• New README section enumerating behavioral differences such as null-only roots, typed schema macros, stored depth, diagnostics keys and relation hooks.

src/nested-set/README.md

Other (8) +27 / -4
extension.neonRegister BuilderMacroResolver and map SoftDeletes macros +12/-0

Register BuilderMacroResolver and map SoftDeletes macros

• Adds parametersSchema/parameters for eloquentBuilderMacros and registers the new resolver service.

src/database/extension.neon

extension.neonMap Scout's Searchable trait to its macro interface +3/-0

Map Scout's Searchable trait to its macro interface

• New extension.neon registering Searchable => SearchableMacros for PHPStan.

src/scout/extension.neon

composer.jsonRegister Scout's PHPStan extension +5/-0

Register Scout's PHPStan extension

• Adds the phpstan.includes entry pointing to the new extension.neon.

src/scout/composer.json

phpstan.neon.distInclude Scout PHPStan extension and update comment +2/-1

Include Scout PHPStan extension and update comment

• Adds src/scout/extension.neon to includes and renames a comment from NodeTrait to HasNode.

phpstan.neon.dist

phpstan.types.neon.distRegister new type-check fixture files +2/-0

Register new type-check fixture files

• Adds the new types/NestedSet, types/Database and types/Scout fixture files to PHPStan type assertions.

phpstan.types.neon.dist

composer.jsonBump root PHPStan dev requirement to ^2.3 +1/-1

Bump root PHPStan dev requirement to ^2.3

• Raises the development PHPStan constraint because 2.2.16 misreports control flow for assertThrows().

composer.json

composer.jsonBump database package PHPStan dev requirement to ^2.3 +1/-1

Bump database package PHPStan dev requirement to ^2.3

• Matches the root PHPStan version bump.

src/database/composer.json

composer.jsonBump foundation package PHPStan dev requirement to ^2.3 +1/-1

Bump foundation package PHPStan dev requirement to ^2.3

• Matches the root PHPStan version bump.

src/foundation/composer.json

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Older tree migrations cannot roll back 🐞 Bug ≡ Correctness
Description
dropColumns() now derives a drop-index name from [_lft, _rgt], replacing the name derived from
[_lft] by the previous helper. When an existing migration created its table with the previous
nested-set helper, its rollback tries to drop an index that was never created and stops before
removing the columns.
Code

src/nested-set/src/NestedSet.php[86]

+        $table->dropIndex([...$scopes, self::LFT, self::RGT]);
Evidence
The changed drop call names the new composite index, while Blueprint constructs an index name from
the exact supplied column list. The diff replaces the old _lft drop even though migrations that
previously called the helper created that older index.

src/nested-set/src/NestedSet.php[83-88]
src/nested-set/src/NestedSet.php[163-168]
src/database/src/Schema/Blueprint.php[1615-1644]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Existing nested-set migrations created an index on `_lft`, but the updated drop helper only attempts to remove the new `_lft, _rgt` index. Rolling back an existing migration therefore fails.
## Fix Focus Areas
- src/nested-set/src/NestedSet.php[83-88]
- src/nested-set/src/NestedSet.php[163-168]
## Recommended Fix
Make dropping nested-set columns compatible with both index layouts, checking which index exists before dropping it. Add a rollback test for a table created with the previous index layout.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Retrying a failed node insert breaks the tree 🐞 Bug ☼ Reliability
Description
restorePendingActionUnlessSaved() puts the consumed append/insert action back whenever save()
returns false or throws. The action has already run insertNode(), which called `makeGap($position,
2)` on other rows before the veto or exception. If the caller did not roll back a transaction, the
retry runs actionAppendOrPrepend() → insertNode() again and opens a second gap. The node lands
in the second gap and the first is left as a hole, so isBroken() and
countErrors()['missing_endpoints'] now report the tree as broken. Before this change the action
was dropped on the first attempt, so a non-transactional retry inserted the node into the gap that
was already open and the tree stayed valid.
Code

src/nested-set/src/HasNode.php[R222-229]

+        try {
+            return $saved = $save();
+        } finally {
+            // Like dirty attributes, the action survives a save that did not complete,
+            // unless an observer queued a new one.
+            if (! $saved && $this->pending === []) {
+                $this->pending = $pending;
+            }
Evidence
callPendingAction (run from fireModelEvent('saving')) clears pending and runs the action. For
a new node, insertAt → insertNode calls makeGap straight away and puts the node's bounds in
memory. A later saving observer veto, or an insert exception, makes save() fail.
restorePendingActionUnlessSaved then sees pending === [] and restores the action. On the next
save(), actionAppendOrPrepend re-reads the widened parent bounds and calls insertNode again,
which runs a second makeGap. The missing-endpoints diagnostic counts any hole in the endpoint
sequence as an error.

src/nested-set/src/HasNode.php[217-231]
src/nested-set/src/HasNode.php[319-335]
src/nested-set/src/HasNode.php[896-910]
src/nested-set/src/Eloquent/QueryBuilder.php[753-771]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
When a save fails, `restorePendingActionUnlessSaved()` restores the pending structural action, even though the action has already changed other rows (for new nodes, `insertNode()` has already run `makeGap`). If no transaction was rolled back, the retry runs the action again and opens a second gap, which leaves a hole in the tree.
## Fix Focus Areas
- src/nested-set/src/HasNode.php[217-231]
- src/nested-set/src/HasNode.php[896-910]
## Recommended Fix
Record whether the action's side effects were already written (for example, set a flag in `callPendingAction()` once the action returns). Restore the action only when it never ran, or when the connection's transaction level shows the caller's transaction was rolled back. Another option: when the restored action is a new-node insert whose gap is already open, reuse the in-memory bounds instead of calling `makeGap` again. Add a test that saves a new child, has a `saving` observer veto it outside a transaction, retries the save, and asserts that `isBroken()` is false.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Typed custom-builder example would crash at class load ✓ Resolved
Description
HasNode now uses HasBuilder but still defines its own newEloquentBuilder(), and the new type
fixture NestedSetTypeMenuItem uses both HasNode and HasBuilder> to keep its custom builder
type. In PHP, two traits that provide different bodies for the same method
(HasNode::newEloquentBuilder and HasBuilder::newEloquentBuilder) cause a fatal 'collision'
compile error unless the class resolves it with insteadof. So the pattern the PR shows for typed
custom nested-set builders, which the database docs also recommend, cannot be used in a real model
without extra resolution the fixture leaves out.
Code

types/NestedSet/NestedSet.php[R38-46]

+class NestedSetTypeMenuItem extends Model
+{
+    use HasNode;
+
+    /** @use HasBuilder<NestedSetTypeMenuBuilder<static>> */
+    use HasBuilder;
+
+    protected static string $builder = NestedSetTypeMenuBuilder::class;
+}
Evidence
HasNode declares use HasBuilder; and also defines newEloquentBuilder() with a different body.
HasBuilder defines its own newEloquentBuilder(), which calls the parent. The fixture class
applies both traits with no insteadof clause. The type file is only analysed by PHPStan and never
executed, so the collision isn't caught there. database.md tells users to add HasBuilder for
custom builder typing, so a nested-set user following that advice would hit the fatal error.

src/nested-set/src/HasNode.php[32-80]
src/database/src/Eloquent/HasBuilder.php[25-33]
types/NestedSet/NestedSet.php[38-46]
src/docs/database.md[496-496]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A model that uses both `HasNode` and `HasBuilder` (as the new type fixture does to keep a custom builder type) gets a fatal trait collision on `newEloquentBuilder()`, because each trait has its own implementation.
## Fix Focus Areas
- types/NestedSet/NestedSet.php[38-46]
- src/nested-set/src/HasNode.php[32-80]
- src/docs/nested-set.md[170-187]
## Recommended Fix
Pick one of these:
- Add `{ HasNode::newEloquentBuilder insteadof HasBuilder; }` to the fixture's trait use, and document the same resolution in the nested-set guide's custom builder section.
- Or give a way to rebind the builder type that doesn't need a second `use HasBuilder` (for example, documenting a `@method static CustomBuilder<static> query()` override).
Also add a runtime test that declares such a model, so the collision is caught in CI.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

4. The new tree example passes an undefined key ✓ Resolved
Description
The new descendantsAndSelf() example passes $mobileId, although the preceding example defines
$mobile and no key variable. Copying the snippet as shown passes an undefined value to a method
requiring a model, integer, or string.
Code

src/docs/nested-set.md[819]

+    ->descendantsAndSelf($mobileId)
Evidence
The preceding snippet defines $mobile, but the new snippet passes $mobileId; the method's
declared argument type does not accept the resulting null value.

src/docs/nested-set.md[805-821]
src/nested-set/src/Eloquent/QueryBuilder.php[263-267]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new documentation example uses an undefined `$mobileId`, so it fails when copied as shown.
## Fix Focus Areas
- src/docs/nested-set.md[805-821]
## Recommended Fix
Pass the `$mobile` model defined in the preceding example, or explicitly define `$mobileId` in the new snippet before using it.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can keep summaries lean with Findings visible per group, which tucks the rest behind a View link

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/nested-set/src/NestedSet.php
Comment thread src/docs/nested-set.md Outdated
Comment thread src/nested-set/src/HasNode.php
Comment thread types/NestedSet/NestedSet.php
@greptile-apps

greptile-apps Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 3/5

[High risk] Upgrades PHPStan and refactors nested set implementation with schema changes.

The PR is not ready to merge because existing migration rollbacks still fail and observer-created children still leave returned parents with stale bounds.

Fix All in Claude CodeFindings

  1. P1 Existing migration rollbacks fail ▶
  2. P1 Created parents return stale bounds ▶

Summary

The PR improves Nested Set writes, eager loading, repair, and PHPStan support.

  • The latest changes make getNodeData() honor explicit table aliases.
  • Assigning parent_id now clears a previously loaded parent.
  • The guide and fixtures now show how to combine HasNode with a custom HasBuilder type.

Reviews (2) · Last reviewed commit: "Resolve the builder trait conflict for t..." · Reviewed by Greptile

{
$table->dropIndex([...$scopes, self::RGT]);
$table->dropIndex([...$scopes, self::LFT]);
$table->dropIndex([...$scopes, self::LFT, self::RGT]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Existing migration rollbacks fail

dropNestedSet() now drops an index on [_lft, _rgt], but tables created before this change have an index on _lft alone. After upgrading, rolling back an existing migration tries to drop a nonexistent index and fails before removing the columns.

Support both index layouts, or provide an upgrade step that runs before existing migrations can be rolled back.

Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this. Hypervel 0.4 is unreleased, so no released version created the (scope..., _lft) index, and dropNestedSet() only needs to drop the indexes nestedSet() creates. Supporting both layouts would mean checking which index exists on every rollback, for tables no released version can have. Aimeos's dropColumnsIndex() likewise drops only its current layout.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That context makes this intentional and valid: since Hypervel 0.4 is unreleased, no released migration can have produced the old _lft-only index, and dropColumns() only needs to reverse the layout created by the current nestedSet() helper. I withdraw this finding.

Comment on lines +1292 to +1294
if ($relation->isNotEmpty()) {
$instance->refreshNode();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Created parents return stale bounds

When a created observer adds a default child through $model->children()->create(...), that child widens the stored parent through a separately loaded model. With no explicit children payload, this condition skips refreshing the model returned by create().

A new root still reports bounds [1, 2] instead of [1, 4]. isLeaf(), getDescendantCount(), and fresh descendant queries then give wrong results. Keep the refresh for observer-created children and add a test for this case.

Suggested change
if ($relation->isNotEmpty()) {
$instance->refreshNode();
}
$instance->refreshNode();

Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this. Any observer that changes the tree through another model instance leaves the saved model's bounds stale, and a plain save() with the same created observer has the same result. The package refreshes bounds where its own operations change them, including create() with a children payload, but it does not re-read every new node to pick up changes made elsewhere, which would add a query to every create. An observer that adds children can call refreshNode() on the parent. Aimeos's create() does not re-read the node either.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That’s fair. The stale instance is caused by an observer mutating the tree through a different model instance, rather than by create()’s own child handling. Re-reading every newly created node would add a query to every create() for a case the package does not otherwise synchronize, and matching Aimeos’s behavior supports keeping this optimization.

I’m withdrawing this finding; an observer that performs such an external tree mutation should refresh the parent instance if it needs to use its bounds immediately.

…by alias

Assigning a new parent_id only queues the append; the new parent is
looked up when the save runs. A parent relation loaded before the
assignment stayed cached, so $node->parent returned the old parent
until the node was saved and refreshed. The parent_id mutator now
unsets the loaded relation, so the next access loads the new parent.
Assigning null already went through makeRoot(), which clears it, and
the raw setParentId() setter is unchanged.

getNodeData() qualified its columns with the model's table name, so a
query that selects from an alias (from('categories as c')) referenced
a table that is not in the statement. It now qualifies through the
builder, which uses the alias when one is set and still avoids
ambiguous columns in joined queries.

Validation: php-cs-fixer, composer analyse, NodeTest and NodeUuidTest
on SQLite, MySQL, MariaDB and PostgreSQL.
HasNode and HasBuilder both define newEloquentBuilder(), so a model
that adds HasBuilder to type a custom builder fails when PHP loads the
class with a trait method collision. The type fixture and the custom
builder test model now keep HasNode's version with insteadof, so the
model builds its Nested Set builder at runtime while PHPStan reads the
builder type from HasBuilder. The test model loads that pattern for
real.

The guide's custom builder section shows the same pattern with a
generic builder, and a collections example uses a literal key in place
of a variable it never defined.

Validation: php-cs-fixer, composer analyse, the types configuration
and NestedSetTest.
@binaryfire
binaryfire merged commit 70968b1 into 0.4 Oct 8, 2026
51 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant