Skip to content

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

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, and tree repair in HasNode

  • Reworks HasNode.fireModelEvent to maintain tree bounds around model events: it vetoes deletion when the row is gone, deletes hard-delete descendants before the parent, closes gaps, and cascades soft delete/restore in chunks with event ordering
  • Replaces per-parent eager loading in AncestorsRelation, DescendantsRelation, and SiblingsRelation with bulk queries using balanced OR groups (max 64 per group) and bulk result matching that keeps query order and excludes a parent's own row
  • Rewrites tree repair in QueryBuilder.fixTree/fixNodes to read plain rows instead of hydrating models, only save changed placements, treat numeric strings and ints as equal, and keep null distinct from zero; columnPatch now handles zero height/distance and getNodeData overrides scope-supplied projections
  • NestedSet indexes now include the right bound column, and SoftDeletes::forceDelete resets its flag on exceptions
  • Adds PHPStan support: a BuilderMacroResolver maps traits (SoftDeletes, Scout's Searchable) to macro interfaces so macros take precedence over same-named scopes, with case-sensitive caching
  • Risk: behavioral changes are concentrated in HasNode deletion/restore ordering, BaseRelation.match (now assigns empty collections to nonmatching parents and caps OR groups at 64), rebuildTree (deletes rows greatest-left-first for MySQL/MariaDB FK checks), and guarded scope attributes now retained in rebuilds — see HasNode.php, BaseRelation.php, and QueryBuilder.php

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

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: hypervel/components/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 2176242d-cd1f-417d-949e-f60156b676c6
📥 Commits

Reviewing files that changed from the base of the PR and between a61c8d5 and 0628959.

📒 Files selected for processing (71)
  • composer.json
  • docs/upstream-sync/sync.yaml
  • phpstan.neon.dist
  • phpstan.types.neon.dist
  • src/database/composer.json
  • src/database/extension.neon
  • src/database/src/Eloquent/SoftDeletes.php
  • src/database/src/PHPStan/BuilderMacroResolver.php
  • src/database/src/PHPStan/ForwardedBuilderMethodExtension.php
  • src/database/src/PHPStan/ForwardedModelMethodExtension.php
  • src/database/src/PHPStan/NamedScopeMethodExtension.php
  • src/database/src/PHPStan/SoftDeletingMacros.php
  • src/docs/database.md
  • src/docs/installation.md
  • src/docs/nested-set.md
  • src/foundation/composer.json
  • src/nested-set/LICENSE.md
  • src/nested-set/README.md
  • src/nested-set/src/Eloquent/AncestorsRelation.php
  • src/nested-set/src/Eloquent/BaseRelation.php
  • src/nested-set/src/Eloquent/Collection.php
  • src/nested-set/src/Eloquent/DescendantsRelation.php
  • src/nested-set/src/Eloquent/QueryBuilder.php
  • src/nested-set/src/Eloquent/SiblingsRelation.php
  • src/nested-set/src/HasNode.php
  • src/nested-set/src/NestedSet.php
  • src/nested-set/src/NestedSetServiceProvider.php
  • src/scout/composer.json
  • src/scout/extension.neon
  • src/scout/src/PHPStan/SearchableMacros.php
  • tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php
  • tests/Database/PHPStan/ForwardedBuilderMethodExtensionTest.php
  • tests/Integration/NestedSet/Database/MariaDb/NestedSetSchemaTest.php
  • tests/Integration/NestedSet/Database/MariaDb/NodeTest.php
  • tests/Integration/NestedSet/Database/MariaDb/NodeUuidTest.php
  • tests/Integration/NestedSet/Database/MariaDb/ScopedNodeTest.php
  • tests/Integration/NestedSet/Database/MariaDb/ScopedNodeUuidTest.php
  • tests/Integration/NestedSet/Database/MySql/NestedSetSchemaTest.php
  • tests/Integration/NestedSet/Database/MySql/NodeTest.php
  • tests/Integration/NestedSet/Database/MySql/NodeUuidTest.php
  • tests/Integration/NestedSet/Database/MySql/ScopedNodeTest.php
  • tests/Integration/NestedSet/Database/MySql/ScopedNodeUuidTest.php
  • tests/Integration/NestedSet/Database/NestedSetDatabaseTestCase.php
  • tests/Integration/NestedSet/Database/Postgres/NestedSetSchemaTest.php
  • tests/Integration/NestedSet/Database/Postgres/NodeTest.php
  • tests/Integration/NestedSet/Database/Postgres/NodeUuidTest.php
  • tests/Integration/NestedSet/Database/Postgres/ScopedNodeTest.php
  • tests/Integration/NestedSet/Database/Postgres/ScopedNodeUuidTest.php
  • tests/Integration/NestedSet/Database/Sqlite/NestedSetDatabaseTest.php
  • tests/NestedSet/Fixtures/Models/CategoryUuid.php
  • tests/NestedSet/Fixtures/Models/MenuItemUuid.php
  • tests/NestedSet/Fixtures/migrations/2025_07_03_000000_create_menu_items_table.php
  • tests/NestedSet/Fixtures/migrations/2025_07_04_000000_create_custom_column_categories_table.php
  • tests/NestedSet/Fixtures/migrations/2025_07_05_000000_create_nullable_menu_items_table.php
  • tests/NestedSet/Fixtures/migrations/2025_07_06_000000_create_constrained_categories_table.php
  • tests/NestedSet/Fixtures/migrations/uuid/2025_07_02_000000_create_categories_table.php
  • tests/NestedSet/Fixtures/migrations/uuid/2025_07_03_000000_create_menu_items_table.php
  • tests/NestedSet/NestedSetCacheTest.php
  • tests/NestedSet/NestedSetMutationLifecycleTest.php
  • tests/NestedSet/NestedSetReadWriteTest.php
  • tests/NestedSet/NestedSetSchemaTest.php
  • tests/NestedSet/NestedSetTest.php
  • tests/NestedSet/NodeTest.php
  • tests/NestedSet/NodeTestBase.php
  • tests/NestedSet/NodeUuidTest.php
  • tests/NestedSet/ScopedNodeTest.php
  • tests/NestedSet/ScopedNodeTestBase.php
  • tests/NestedSet/ScopedNodeUuidTest.php
  • types/Database/Eloquent/SoftDeletes.php
  • types/NestedSet/NestedSet.php
  • types/Scout/Searchable.php
💤 Files with no reviewable changes (3)
  • tests/Integration/NestedSet/Database/Sqlite/NestedSetDatabaseTest.php
  • tests/Integration/NestedSet/Database/NestedSetDatabaseTestCase.php
  • tests/NestedSet/NestedSetMutationLifecycleTest.php

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The pull request adds PHPStan support for soft-delete and Scout builder macros. It also changes nested-set lifecycle, query, repair, and eager-loading code, updates documentation and schema indexes, and expands shared and database-specific tests.

Changes

PHPStan builder macro support

Layer / File(s) Summary
Macro resolution and soft-delete methods
src/database/src/PHPStan/*, src/database/extension.neon, src/database/src/Eloquent/SoftDeletes.php, tests/Database/PHPStan/*, types/Database/Eloquent/SoftDeletes.php
PHPStan resolves trait-provided builder macros alongside named scopes and forwarded methods. Soft-delete macro signatures and type checks cover model queries, custom builders, and relationships. forceDelete() resets its state after a thrown delete.
Scout macro registration and analysis
src/scout/*, phpstan.neon.dist, phpstan.types.neon.dist, types/Scout/Searchable.php, src/docs/installation.md
The Scout extension declares and registers searchable and unsearchable. PHPStan includes the extension and scans its declarations. The installation guide describes Scout support.
PHPStan validation and setup
composer.json, src/database/composer.json, src/foundation/composer.json, tests/Database/PHPStan/*, src/docs/database.md
The PHPStan development requirement changes to ^2.3. The database guide adds soft-delete macro support to its PHPStan description.

Nested-set behavior and coverage

Layer / File(s) Summary
Node lifecycle and structural mutations
src/nested-set/src/HasNode.php, src/database/src/Eloquent/SoftDeletes.php, tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php
HasNode handles tree maintenance during model events, descendant deletion and restoration, and persisted-state preparation for mutations. Save failures can restore a pending action. forceDelete() resets its flag in a finally block.
Tree queries, repair, and schema
src/nested-set/src/Eloquent/QueryBuilder.php, src/nested-set/src/NestedSet.php, src/nested-set/src/NestedSetServiceProvider.php, tests/NestedSet/NestedSetReadWriteTest.php, tests/NestedSet/NestedSetSchemaTest.php, types/NestedSet/NestedSet.php
Nested-set lookups and diagnostics inherit the query’s read/write route. Repair reads plain rows and saves changed placements. Nested-set indexes use the left and right bounds together. Tests cover query routing, schema, and PHPStan types.
Batched eager relation matching
src/nested-set/src/Eloquent/BaseRelation.php, src/nested-set/src/Eloquent/AncestorsRelation.php, src/nested-set/src/Eloquent/DescendantsRelation.php, src/nested-set/src/Eloquent/SiblingsRelation.php
Eager constraints are grouped, and relation results are matched in batches. The relation implementations use scope-aware matching and retain query order.
Tree collections and package guidance
src/nested-set/src/Eloquent/Collection.php, src/docs/nested-set.md, src/nested-set/README.md, src/nested-set/LICENSE.md, docs/upstream-sync/sync.yaml
Collection tree conversion retains existing relations and builds flattened results as arrays before creating a collection. Documentation and upstream tracking describe nested-set setup, queries, mutations, scope behavior, and package differences.
Shared and database-specific test coverage
tests/NestedSet/*, tests/NestedSet/Fixtures/*, tests/Integration/NestedSet/Database/*, types/NestedSet/NestedSet.php
Shared test suites cover scoped and UUID-backed trees. Database-specific classes inherit those suites for MariaDB, MySQL, and PostgreSQL. New fixtures and tests cover schema variations, scoped operations, and eager relation projections.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~60 minutes

Change: Bug fix · Severity of issue fixed: Low

Merge Risk: ⚪ Minimal · up to 06289

This change improves nested-set lifecycle, query, repair, and eager-loading behavior and expands PHPStan macro support. The available evidence shows no concrete defect, so the change appears ready to merge after the normal CI checks.

🚥 Pre-merge checks | ✅ 3 | ❌ 1 | ❓ 1

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed and covers the problem, implementation changes, performance evidence, tradeoffs, tests, and documentation. However, it does not use the required template headings, select a… Add the required contribution type selection. Organize the content under Problem and change, Supporting evidence, and Verification. Record the commands run and their results, including the required repository-root composer fix run. Complete…
Docstring Coverage ❓ Inconclusive Docstring coverage is 72.39% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 268 functions across 50 files. (16 skippe… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the primary Nested Set changes: improved correctness and better eager-loading scale. It is concise and related to the main changes, although it does not mention repair imp…
Full details: Docstring Coverage

Explanation

Docstring coverage is 72.39% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 268 functions across 50 files. (16 skipped: 14 unsupported, 2 over the file limit.)

Full details: Description check

Explanation

The description is detailed and covers the problem, implementation changes, performance evidence, tradeoffs, tests, and documentation. However, it does not use the required template headings, select a contribution type, list verification commands and results, or complete the submission checklist.

Resolution

Add the required contribution type selection. Organize the content under Problem and change, Supporting evidence, and Verification. Record the commands run and their results, including the required repository-root composer fix run. Complete the Before submitting checklist.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • 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.

@binaryfire

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@binaryfire

Copy link
Copy Markdown
Member Author

@cubic-dev-ai review

@cubic-dev-ai

cubic-dev-ai Bot commented Oct 8, 2026

Copy link
Copy Markdown

@cubic-dev-ai review

@binaryfire cubic can't start this review because your workspace has reached its free monthly review limit. cubic has reviewed 124,545 of the 120,000 allowed lines of code this month. Reviews resume on 10 October 2026 (in 2 days). Paid plans include much higher monthly review limits. Upgrade now to resume reviews.

To help optimise your usage, you can tune cubic to get the most out of your usage limits:

Learn more →

Comment thread src/nested-set/src/NestedSet.php
Comment thread src/nested-set/src/Eloquent/Collection.php
Comment thread src/nested-set/src/Eloquent/QueryBuilder.php Outdated
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Improve Nested Set correctness, eager-loading scale, and repair

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

Grey Divider

AI Description

• Preserve tree consistency across failed saves, vetoed deletes, descendant cascades, and restores.
• Scale eager loading and repair through bulk matching, selective hydration, and improved indexes.
• Add typed builders and macros, cross-database tests, and transaction guidance.
Diagram

graph TD
  Model["HasNode model"] --> Events["Mutation events"] --> Builder["Nested Set builder"] --> Rows[("Tree rows")]
  Model --> Relations["Tree relations"] --> Matcher["Bulk eager matching"]
  Builder --> Repair["Selective repair"] --> Rows
  Macro["PHPStan macro resolver"] --> Model
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Automatically transact structural writes
  • ➕ Would protect callers who omit transactions around veto-prone operations.
  • ➖ Changes transaction boundaries and locking behavior.
  • ➖ Cannot choose application-appropriate tree locks or isolation levels.
2. Use recursive SQL for large eager loads
  • ➕ Could avoid SQLite's expensive preparation of thousands of descendant ranges.
  • ➖ Requires database-specific implementations.
  • ➖ Complicates compatibility with existing interval-based queries.

Recommendation: Keep the cross-driver interval batching and selective repair. Retain caller-owned transactions, but emphasize the documented transaction-and-lock requirement: a veto can occur after bounds have changed.

Files changed (68) +8372 / -4727

Enhancement (10) +630 / -257
BuilderMacroResolver.phpResolve trait-provided builder macros +99/-0

Resolve trait-provided builder macros

• Adds a reusable PHPStan resolver mapping model traits to macro-signature interfaces while preserving generic and fluent types.

src/database/src/PHPStan/BuilderMacroResolver.php

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

Resolve builder macros before named scopes

• Integrates macro resolution into forwarded builder and relation calls while respecting runtime precedence.

src/database/src/PHPStan/ForwardedBuilderMethodExtension.php

ForwardedModelMethodExtension.phpPreserve macro precedence on model calls +7/-2

Preserve macro precedence on model calls

• Recognizes trait-provided builder macros when resolving forwarded model methods.

src/database/src/PHPStan/ForwardedModelMethodExtension.php

SoftDeletingMacros.phpDeclare SoftDeletes macro signatures +55/-0

Declare SoftDeletes macro signatures

• Adds generic PHPStan signatures for trash visibility, restore, and restore-or-create methods.

src/database/src/PHPStan/SoftDeletingMacros.php

AncestorsRelation.phpScale eager ancestor queries and matching +161/-28

Scale eager ancestor queries and matching

• Uses a balanced CASE expression per scope for eager constraints and a stack sweep to match ancestors across parents.

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

BaseRelation.phpIntroduce bulk eager-matching hooks +129/-67

Introduce bulk eager-matching hooks

• Replaces per-parent matching with matchMany(), bounds OR-group depth, and supplies ordered scope-aware result buckets.

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

DescendantsRelation.phpBatch descendant eager loading +91/-89

Batch descendant eager loading

• Skips leaves, groups range constraints to avoid expression-depth failures, and matches descendants with binary search.

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

SiblingsRelation.phpGroup sibling constraints and matches +48/-63

Group sibling constraints and matches

• Uses balanced eager constraints and scope-plus-parent buckets instead of comparing every result against every parent.

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

NestedSet.phpCover both bounds in the left-bound index +5/-2

Cover both bounds in the left-bound index

• Creates and drops a scope-prefixed (_lft, _rgt) index instead of the left-only index.

src/nested-set/src/NestedSet.php

SearchableMacros.phpDeclare Scout search macros +23/-0

Declare Scout search macros

• Provides searchable() and unsearchable() builder-macro signatures.

src/scout/src/PHPStan/SearchableMacros.php

Bug fix (5) +510 / -258
SoftDeletes.phpReset force-delete state when deletion throws +10/-5

Reset force-delete state when deletion throws

• Clears forceDeleting in a finally block after delete(), including when deletion throws.

src/database/src/Eloquent/SoftDeletes.php

NamedScopeMethodExtension.phpDefer named scopes to same-named macros +7/-7

Defer named scopes to same-named macros

• Matches Eloquent's macro-before-scope precedence and retains case-sensitive cache keys.

src/database/src/PHPStan/NamedScopeMethodExtension.php

Collection.phpPreserve externally loaded parent relations +31/-14

Preserve externally loaded parent relations

• Tree linking no longer clears a parent relation loaded outside the collection; collection methods gain generic annotations.

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

QueryBuilder.phpFix lookups and make tree repair selective +199/-113

Fix lookups and make tree repair selective

• Qualifies structural lookups, fixes zero-offset SQL, honors writer routing, and compares plain repair rows before hydrating changed models.

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

HasNode.phpCorrect structural mutation and cascade lifecycles +263/-119

Correct structural mutation and cascade lifecycles

• Preserves actions across failed saves, centralizes event upkeep, orders hard-delete cascades children-first, restores hidden descendants, and adds generic types.

src/nested-set/src/HasNode.php

Tests (38) +6878 / -4154
DatabaseEloquentSoftDeletesIntegrationTest.phpAssert force-delete state is reset +1/-0

Assert force-delete state is reset

• Adds an assertion that forceDeleting is false after force deletion.

tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php

ForwardedBuilderMethodExtensionTest.phpSupply the new resolver in extension tests +8/-1

Supply the new resolver in extension tests

• Updates the constructor test for the macro-resolver dependency.

tests/Database/PHPStan/ForwardedBuilderMethodExtensionTest.php

NestedSetSchemaTest.phpReuse shared schema assertions on MariaDB +2/-2

Reuse shared schema assertions on MariaDB

• Switches the MariaDB schema subclass to the expanded shared schema suite.

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

NodeTest.phpRun integer node cases on MariaDB +13/-0

Run integer node cases on MariaDB

• Adds a database-gated subclass of the shared integer node suite.

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

NodeUuidTest.phpRun UUID node cases on MariaDB +13/-0

Run UUID node cases on MariaDB

• Adds a database-gated subclass of the shared UUID node suite.

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

ScopedNodeTest.phpRun scoped integer cases on MariaDB +13/-0

Run scoped integer cases on MariaDB

• Adds a database-gated subclass of the scoped integer suite.

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

ScopedNodeUuidTest.phpRun scoped UUID cases on MariaDB +13/-0

Run scoped UUID cases on MariaDB

• Adds a database-gated subclass of the scoped UUID suite.

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

NestedSetSchemaTest.phpReuse shared schema assertions on MySQL +2/-2

Reuse shared schema assertions on MySQL

• Switches the MySQL schema subclass to the expanded shared schema suite.

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

NodeTest.phpRun integer node cases on MySQL +13/-0

Run integer node cases on MySQL

• Adds a database-gated subclass of the shared integer node suite.

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

NodeUuidTest.phpRun UUID node cases on MySQL +13/-0

Run UUID node cases on MySQL

• Adds a database-gated subclass of the shared UUID node suite.

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

ScopedNodeTest.phpRun scoped integer cases on MySQL +13/-0

Run scoped integer cases on MySQL

• Adds a database-gated subclass of the scoped integer suite.

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

ScopedNodeUuidTest.phpRun scoped UUID cases on MySQL +13/-0

Run scoped UUID cases on MySQL

• Adds a database-gated subclass of the scoped UUID suite.

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

NestedSetSchemaTest.phpReuse shared schema assertions on PostgreSQL +2/-2

Reuse shared schema assertions on PostgreSQL

• Switches the PostgreSQL schema subclass to the expanded shared schema suite.

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

NodeTest.phpRun integer node cases on PostgreSQL +13/-0

Run integer node cases on PostgreSQL

• Adds a database-gated subclass of the shared integer node suite.

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

NodeUuidTest.phpRun UUID node cases on PostgreSQL +13/-0

Run UUID node cases on PostgreSQL

• Adds a database-gated subclass of the shared UUID node suite.

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

ScopedNodeTest.phpRun scoped integer cases on PostgreSQL +13/-0

Run scoped integer cases on PostgreSQL

• Adds a database-gated subclass of the scoped integer suite.

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

ScopedNodeUuidTest.phpRun scoped UUID cases on PostgreSQL +13/-0

Run scoped UUID cases on PostgreSQL

• Adds a database-gated subclass of the scoped UUID suite.

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

CategoryUuid.phpAdd UUID category fixture model +14/-0

Add UUID category fixture model

• Extends the category fixture with UUID key generation.

tests/NestedSet/Fixtures/Models/CategoryUuid.php

MenuItemUuid.phpAdd UUID scoped-node fixture model +14/-0

Add UUID scoped-node fixture model

• Extends the menu-item fixture with UUID key generation.

tests/NestedSet/Fixtures/Models/MenuItemUuid.php

2025_07_03_000000_create_menu_items_table.phpAdjust menu-item fixture migration +1/-0

Adjust menu-item fixture migration

• Updates the migration used by the reorganized shared test suites.

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

2025_07_04_000000_create_custom_column_categories_table.phpAdd renamed-column tree fixture +32/-0

Add renamed-column tree fixture

• Creates a category table with lft, rgt, level, and ancestor_id columns for custom-column tests.

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

2025_07_05_000000_create_nullable_menu_items_table.phpAdd nullable-scope tree fixture +31/-0

Add nullable-scope tree fixture

• Creates a scoped menu-item table whose scope column can be null.

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

2025_07_06_000000_create_constrained_categories_table.phpAdd foreign-key-constrained tree fixture +37/-0

Add foreign-key-constrained tree fixture

• Creates self-referencing categories and related items for delete-order and restriction tests.

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

2025_07_02_000000_create_categories_table.phpCreate UUID category fixture schema +31/-0

Create UUID category fixture schema

• Adds the category migration used by shared UUID-key tests.

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

2025_07_03_000000_create_menu_items_table.phpCreate UUID menu-item fixture schema +31/-0

Create UUID menu-item fixture schema

• Adds the scoped menu-item migration used by shared UUID-key tests.

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

NestedSetCacheTest.phpTest Nested Set cache isolation +48/-0

Test Nested Set cache isolation

• Verifies per-class soft-delete and node-trait caches and flush behavior.

tests/NestedSet/NestedSetCacheTest.php

NestedSetReadWriteTest.phpTest writer-aware lookups and diagnostics +52/-0

Test writer-aware lookups and diagnostics

• Checks descendant key lookups and diagnostic methods with replica versus useWritePdo() routing.

tests/NestedSet/NestedSetReadWriteTest.php

NestedSetSchemaTest.phpVerify exact cross-driver nested-set schemas +59/-36

Verify exact cross-driver nested-set schemas

• Checks structural column types and indexes under a prefixed connection and tests index removal.

tests/NestedSet/NestedSetSchemaTest.php

NestedSetTest.phpExercise supported custom builder selection +82/-49

Exercise supported custom builder selection

• Adds cases for attributed and static-property builders, including rejection of incompatible builders.

tests/NestedSet/NestedSetTest.php

NodeTest.phpSpecialize node tests for integer keys +529/-3218

Specialize node tests for integer keys

• Moves shared cases into NodeTestBase and keeps integer-only cases and setup in this subclass.

tests/NestedSet/NodeTest.php

NodeTestBase.phpShare node behavior tests across drivers and keys +4329/-0

Share node behavior tests across drivers and keys

• Consolidates node cases into a prefixed-connection suite with per-class migrations, coroutine-local seeding, and expanded mutation, eager-loading, and repair coverage.

tests/NestedSet/NodeTestBase.php

NodeUuidTest.phpRun shared node cases with UUID keys +30/-0

Run shared node cases with UUID keys

• Selects the UUID category model, migration path, and deterministic fixture keys.

tests/NestedSet/NodeUuidTest.php

ScopedNodeTest.phpSpecialize scoped-node tests for integer keys +36/-844

Specialize scoped-node tests for integer keys

• Moves shared scoped cases into ScopedNodeTestBase and retains integer-key-specific setup.

tests/NestedSet/ScopedNodeTest.php

ScopedNodeTestBase.phpShare scoped-node tests across drivers and keys +1066/-0

Share scoped-node tests across drivers and keys

• Adds a prefixed-connection shared suite covering scope isolation, mutations, eager loading, repair, and UUID compatibility.

tests/NestedSet/ScopedNodeTestBase.php

ScopedNodeUuidTest.phpRun shared scoped cases with UUID keys +30/-0

Run shared scoped cases with UUID keys

• Selects the UUID menu-item model, migration path, and deterministic fixture keys.

tests/NestedSet/ScopedNodeUuidTest.php

SoftDeletes.phpAssert SoftDeletes macro types +119/-0

Assert SoftDeletes macro types

• Checks macro return types and precedence on static models, custom builders, and relations.

types/Database/Eloquent/SoftDeletes.php

NestedSet.phpAssert generic Nested Set types +80/-0

Assert generic Nested Set types

• Checks model-preserving builder, relation, collection, and custom-builder inference.

types/NestedSet/NestedSet.php

Searchable.phpAssert Scout macro types +56/-0

Assert Scout macro types

• Checks searchable and unsearchable typing on builders and relations.

types/Scout/Searchable.php

Documentation (7) +327 / -54
sync.yamlRecord Nested Set upstream sync +4/-3

Record Nested Set upstream sync

• Records the reviewed Aimeos commit, date, PR, and file-mapping notes for future syncs.

docs/upstream-sync/sync.yaml

database.mdDocument soft-delete method analysis +1/-1

Document soft-delete method analysis

• Notes that the database PHPStan extension understands soft-delete query methods.

src/docs/database.md

installation.mdDocument Scout PHPStan setup +3/-1

Document Scout PHPStan setup

• Explains automatic loading and manual inclusion of Scout's searchable-method extension.

src/docs/installation.md

nested-set.mdExpand Nested Set usage and safety guidance +287/-47

Expand Nested Set usage and safety guidance

• Adds schema and custom-column setup, creation and deletion examples, transaction and locking guidance, and performance caveats.

src/docs/nested-set.md

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

Credit Aimeos and Lunar maintainers

• Adds upstream maintenance credits to the Nested Set license.

src/nested-set/LICENSE.md

README.mdDocument differences from upstream +26/-2

Document differences from upstream

• Explains Hypervel's schema macros, root semantics, diagnostics, repair behavior, relation hooks, and descendant event defaults.

src/nested-set/README.md

NestedSetServiceProvider.phpClarify the bundled schema macros +2/-0

Clarify the bundled schema macros

• Documents that the macros include depth and indexes rather than upstream's separate helpers.

src/nested-set/src/NestedSetServiceProvider.php

Other (8) +27 / -4
composer.jsonRequire PHPStan 2.3 in development +1/-1

Require PHPStan 2.3 in development

• Raises the root development requirement from PHPStan 2.2 to 2.3.

composer.json

phpstan.neon.distLoad Scout's PHPStan extension +2/-1

Load Scout's PHPStan extension

• Includes Scout's extension configuration and updates a HasNode baseline comment.

phpstan.neon.dist

phpstan.types.neon.distInclude Scout macros in type checks +2/-0

Include Scout macros in type checks

• Includes Scout's extension and scans its PHPStan source directory.

phpstan.types.neon.dist

composer.jsonRequire PHPStan 2.3 for database development +1/-1

Require PHPStan 2.3 for database development

• Raises the database package's development PHPStan constraint.

src/database/composer.json

extension.neonRegister the builder-macro resolver +12/-0

Register the builder-macro resolver

• Defines the trait-to-interface macro map, registers the resolver, and maps SoftDeletes macros.

src/database/extension.neon

composer.jsonRequire PHPStan 2.3 for foundation development +1/-1

Require PHPStan 2.3 for foundation development

• Raises the foundation package's development PHPStan constraint.

src/foundation/composer.json

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

Expose Scout's PHPStan extension

• Registers extension.neon for Composer-based PHPStan extension discovery.

src/scout/composer.json

extension.neonMap Searchable to its macro declarations +3/-0

Map Searchable to its macro declarations

• Adds Scout's contribution to the shared trait-to-builder-macro map.

src/scout/extension.neon

@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. Retrying a vetoed save leaves tree gaps 🐞 Bug ≡ Correctness
Description
restorePendingActionUnlessSaved() restores an insertion action after saving returns false, even
though insertNode() has already widened the persisted tree. When a later observer vetoes a new
node's save outside a transaction, retrying that model creates a second gap before inserting its
row.
Code

src/nested-set/src/HasNode.php[R225-228]

+            // 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
Model::save() returns false when its saving event is vetoed. The nested-set event handler runs
the pending action before calling the parent event handler; insertion calls makeGap() before the
row is saved. The added finally block then restores that consumed action whenever the save
returned false.

src/database/src/Eloquent/Model.php[1478-1492]
src/nested-set/src/HasNode.php[101-104]
src/nested-set/src/HasNode.php[181-195]
src/nested-set/src/HasNode.php[217-229]
src/nested-set/src/HasNode.php[895-909]

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 vetoed save can leave an insertion gap in the database, but the new retry wrapper restores the insertion action and runs it again on the next save.
## Fix Focus Areas
- src/nested-set/src/HasNode.php[217-230]
- src/nested-set/src/HasNode.php[895-909]
## Recommended Fix
Distinguish actions whose database changes were rolled back from actions whose changes remain persisted. Do not replay a structural action against an unrolled-back gap; add a test that vetoes an insertion outside a transaction and retries the same model.

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



Remediation recommended

2. Rolling back older nested set tables fails 🐞 Bug ☼ Reliability
Description
NestedSet::dropColumns(), called by dropNestedSet(), now drops only the `[...$scopes, _lft,
_rgt] index instead of handling the older [...$scopes, _lft]` index. When a down migration runs
against a table created by the previous helper, the requested index does not exist, so the drop
fails on MySQL, MariaDB and PostgreSQL and the old index remains.
Code

src/nested-set/src/NestedSet.php[R85-87]

      $table->dropIndex([...$scopes, self::RGT]);
-        $table->dropIndex([...$scopes, self::LFT]);
+        $table->dropIndex([...$scopes, self::LFT, self::RGT]);
      $table->dropIndex([...$scopes, self::PARENT_ID, self::LFT]);
Evidence
Before this change, addIndexes() created an index on [...$scopes, LFT] and dropColumns()
removed it; the diff changes both to [...$scopes, LFT, RGT] without a fallback or upgrade step.
dropNestedSet() calls that teardown, and Blueprint::dropIndex() queues the requested drop
unconditionally, so an existing table with only the old index cannot satisfy it. The guide describes
the new index set but gives no migration path for existing tables.

src/nested-set/src/NestedSet.php[83-89]
src/nested-set/src/NestedSet.php[163-169]
src/docs/nested-set.md[99-99]
src/docs/nested-set.md[1147-1147]
src/nested-set/src/NestedSet.php[83-88]
src/nested-set/src/NestedSet.php[163-168]
src/nested-set/src/NestedSetServiceProvider.php[35-37]
src/database/src/Schema/Blueprint.php[505-512]

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 tables retain the old `[...$scopes, LFT]` index, but `dropNestedSet()` now requests removal of only `[...$scopes, LFT, RGT]`, preventing rollback on those tables.
## Fix Focus Areas
- src/nested-set/src/NestedSet.php[83-89]
- src/nested-set/src/NestedSet.php[163-168]
- src/docs/nested-set.md[1147-1147]
## Recommended Fix
Make `dropColumns()` check which left-bound index exists, using `Schema::hasIndex()` or the connection's schema builder, then drop the matching old or new index. Test rollback of a schema created with the previous left-bound index. Add a guide note explaining that existing tables keep the old index until migrated; alternatively, document an upgrade migration that replaces it with the new index before `dropNestedSet()` is used.

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


3. Typed custom builder example crashes when run ✓ Resolved
Description
HasNode now uses HasBuilder and also declares its own newEloquentBuilder(). A model that
additionally writes use HasBuilder; to type a custom builder therefore gets two different trait
methods named newEloquentBuilder (HasNode's and HasBuilder's) and PHP stops with a trait collision
compile error. The new PHPStan fixture NestedSetTypeMenuItem uses exactly this pattern to show
that "custom builders keep their type", but types files are only analysed and never executed, so
nothing catches the crash.
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
HasBuilder declares newEloquentBuilder() (HasBuilder.php lines 29-32). HasNode declares its
own newEloquentBuilder() and now also uses HasBuilder. NestedSetTypeMenuItem uses both
HasNode and HasBuilder directly and does not define newEloquentBuilder() itself or resolve the
clash with insteadof. PHP does not allow two different non-abstract trait methods with the same
name in one class, so loading this class is a fatal error. Users who copy the advertised typing
pattern hit the same crash.

types/NestedSet/NestedSet.php[38-46]
src/database/src/Eloquent/HasBuilder.php[24-32]
src/nested-set/src/HasNode.php[32-80]

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` gets two conflicting `newEloquentBuilder()` trait methods and fails to load. The PHPStan fixture models this pattern, so users are pointed toward code that crashes.
## Fix Focus Areas
- types/NestedSet/NestedSet.php[38-46]
- src/nested-set/src/HasNode.php[32-80]
- src/docs/nested-set.md[170-180]
## Recommended Fix
Pick one of these:
- Change the fixture and the docs to type a custom builder without re-using `HasBuilder`, for example with a `@method static CustomBuilder<static> query()` docblock or another PHPStan-supported approach.
- Or add `insteadof` resolution to the fixture (`use HasNode, HasBuilder { HasNode::newEloquentBuilder insteadof HasBuilder; }`) and document that this is required.
Also add a runtime test that instantiates such a model so the pattern is checked by execution, not only by analysis.

ⓘ 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/HasNode.php
Comment thread src/nested-set/src/NestedSet.php
Comment thread types/NestedSet/NestedSet.php
…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 bf1e605 into 0.4 Oct 8, 2026
51 checks passed
@binaryfire
binaryfire deleted the upstream-sync-nested-set-reconciliation branch October 10, 2026 17:14
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