Patch release: one aggregate-correctness fix.
AVG display column no longer drifts by one unit in the last decimal on MySQL 9. The column was written as 1.0 * sum / NULLIF(count, 0); the scale-1 multiplier gave the quotient scale 1 + div_precision_increment, which was then rounded a second time when stored into the DECIMAL(_, 4) column. The two roundings disagree at a 4th-decimal half-boundary — MySQL 8 rounded half-down (matching a fresh AVG() read) but MySQL 9 rounds half-up, so e.g. 1160/11 stored as 105.4546 while a fresh single-rounded AVG() returned 105.4545, reported as aggregate drift. The coercion multiplier is now scale 6 so the single final round into the column matches the fresh value on every backend. Weighted-avg is unchanged. Surfaced by the non-gating MySQL 9 ceiling lane. (#224)Full Changelog: https://github.com/vusys/laravel-nestedset/compare/v0.24.3...v0.24.4
Patch: three tree-correctness fixes, all surfaced by the new Runabout
journey suite (seeded, shuffled, shrinking property tests for
order-dependent invariants) — two TreeDiff::apply() corruptions on
add/remove diffs, and a soft-delete restore() that could leave a live
child under a trashed parent.
restore() now rejects a node whose parent is still soft-deleted,
throwing the new TrashedAncestorException. The restore cascade only
walks down (it brings back the anchor plus its same-stamp
descendants but never restores ancestors), so restoring a mid-subtree
node left a live child parented under a trashed one — the exact "live
child under a trashed parent" state the insert / factory path already
refuses with TrashedTargetException. The guard keeps that invariant
total in both directions: restore outward-in (parent before
child). Restoring from the top of a trashed subtree (parent live, or a
root) is unchanged, and a trashed child under a live parent is still
fine. The check is a single indexed lookup on the parent's
deleted_at and runs before any write, so a rejected restore leaves
the tree and its aggregates untouched. Closes #218; surfaced by the
soft-delete Runabout journey.restore() throws, so the downstream drift can't occur.TreeDiff::apply() corrupted the tree when a diff's removed set
contained both a node and one of its descendants, on a hard-delete
model. TreeDiffApplier::doRemoves() loaded every removed row up
front, then deleted each using stale in-memory bounds. Deleting an
ancestor cascade-removes its descendants and closes their gaps, so the
loop then re-deleted an already-gone descendant with stale bounds,
double-closing an ancestor's rgt. Each removed row is now reloaded
against the live tree immediately before its delete: rows a prior
cascade already took are skipped, and survivors close their gap with
current bounds. Soft-delete models were unaffected.TreeDiff::apply() threw moveToSiblingPosition(): position must be in [0, N] when a diff added a node at a sibling position that a later
move would fill. apply() runs its phases add → move → remove →
modify, so when a parent's final children come from a mix of added and
moved-in nodes, an added node's recorded position could exceed the
child count present during the adds phase. The applier now clamps the
add-time position to the current tail and lets the moves phase settle
the final sibling order.Patch: stop a bounds-stale instance from drifting aggregates on a source-column update.
lft/rgt had gone stale banded the delta onto the wrong ancestor chain. A source-column save rewrites only the dirty column, never the structural bounds, so an instance shifted by an earlier move/append (but not refreshed) still carried its old bounds while the DB row held the current ones. The saved-hook delta pass keyed its ancestor UPDATE off those stale bounds — missing the node itself and spilling the delta onto whatever now occupied the old position, producing permanent silent drift. The delta pass now re-reads lft/rgt/depth from the database before banding (locking the row when a transaction is open), matching the discipline already used on the delete, move and restore paths. Found by the scheduled aggregate stale-instance fuzzer.Full changelog: https://github.com/vusys/laravel-nestedset/compare/v0.24.1...v0.24.2
Patch: fail loud on a misconfigured NodeTrait model instead of silently corrupting the tree.
MisconfiguredNodeException, plus a [@phpstan-require-implements](https://github.com/phpstan-require-implements) MaintainsTreeAggregates constraint on NodeTrait. A model that composes the trait but omits implements MaintainsTreeAggregates is now caught both statically (composer analyse) and at runtime on the first save().NodeTrait model missing implements MaintainsTreeAggregates used to insert rows with lft = rgt = 0 and no error. Every lifecycle listener gated on that interface, so saveAsRoot() / appendToNode()->save() placed nothing while appearing to succeed (bulkInsertTree() masked it by writing the bounds attributes directly). The saving listener now throws MisconfiguredNodeException instead of silently producing an invalid_bounds row.Full changelog: https://github.com/vusys/laravel-nestedset/compare/v0.24.0...v0.24.1
Audit follow-ups on v0.23.0: cross-backend correctness (reserved-word columns, MariaDB fresh-reads, MySQL/MariaDB index-name limits), soft-delete structural semantics, concurrency hardening, and new coherence fuzzers. Validated across SQLite, MySQL, MariaDB and PostgreSQL.
[scope…, parent_id] index in the nestedSet() Blueprint macro (decision #6) — serves children(), whereIsRoot() and fixTree()'s parent walk on PostgreSQL/SQLite (MySQL got one free off its FK). Dropped by dropNestedSet().TrashedTargetException — placing a live node relative to a soft-deleted anchor now throws.append/prepend/insertBefore/insertAfterNode onto a soft-deleted target now throw TrashedTargetException (was silently allowed).reorderChildrenBy() now reasons over the raw sibling set (live + trashed), matching reorderChildren().nestedSet() index names bounded to 64 chars (MySQL/MariaDB cap) via boundedIndexName(); existing schemas keep Laravel's native name.TreeDiff orders flat-snapshot siblings by lft (not input order), eliminating phantom Moved entries.even_bounds_width check — reserved-word column names (left/order) no longer break mutations/repair on PG/MySQL/MariaDB.withFreshAggregates() + ->limit()/->offset() no longer hard-errors (1235).immutable_datetime cast (was silently no-opping).mb_strlen() offset (multibyte-safe).deleting hook re-reads bounds under FOR UPDATE.READ COMMITTED recompute window + crossing-move deadlock-ordering documented.Full changelog: https://github.com/vusys/laravel-nestedset/compare/v0.23.0...v0.24.0
A correctness, concurrency and pre-1.0 hardening pass (#202). Validated across SQLite and MySQL — full suite + every seeded fuzzer.
moveToSiblingPosition() is now 0-based (was 1-based) — matches moveTo(), TreeDiff, and the factory.Exception suffix: DuplicatePathSegment, EmptyPathSegment, InvalidPathSegment, NonDeterministicPathSegment, PathTooLong → *Exception.toTree() / toFlatTree() now return a forest on partial fetches (parent-absent nodes become top-level) instead of silently dropping disconnected nodes.save() throws instead of dropping the first; changing a scope column on an existing node throws ScopeViolationException.DuplicatePathSegmentException (was a silent duplicate).false on never-placed nodes instead of throwing; freshAggregate() now returns a value cast to the column type.NestedSetException marker interface on every package exception.countErrors() gains overlapping_bounds and even_bounds_width checks.bulkInsertTree() / fromJsonTree(), and working includeKeys (explicit primary keys).makeRoot(); fork-based move/reorder/makeRoot concurrency tests.children() eager-loading.JsonAgg order-insensitive drift detection.FOR UPDATE); reorderChildren() reads inside the locked transaction; deferred maintenance repairs from the tree root; anchored fixTree() rebuilds paths against fresh bounds.Full detail in CHANGELOG.md.
…12:00:00 vs descendants …12:00:00.000000), so restoring an outer ancestor could restore part of an interleaved cascade and strand a trashed node under a live parent (diverging across backends). The cascade now stamps descendants with the anchor's exact seconds-precision value.fixTree() hung / OOM'd on parent_id cycles — the documented cycle-recovery tool now terminates (visited-set guards in the subtree walk).NULL ↔ value source updates and listener-contribution transitions (the SUM-style NULL → 0 coercion leaked into the MIN/MAX update paths).insertAfterNode/insertBeforeNode could stamp a stale parent_id, and the before-move aggregate hook used stale bounds. Both now read fresh.saving/creating listener returning false committed structural SQL (gap/move) without the row write; the cancel now rolls back.delete()/forceDelete() were not wrapped in the auto-transaction, so a throw mid-pipeline left a permanent hole — now wrapped like save().saveQuietly() silently dropped queued placements — both now guarded.bulkInsertTree() trusted the anchor's stale in-memory bounds — now reads + locks the anchor row inside its transaction.withDeferredAggregateMaintenance() swallowed repair failures on the success path — the failure now propagates.TreeDiff::apply() discarded recorded sibling positions and deleted retained children before moving them; fromJsonTree() returned the wrong nodes (DFS pre-order slice bug).isDescendantOf()/isAncestorOf() ignored scope (cross-tree false positives); relations had no ORDER BY; #[NestedSetScope] was not inherited by subclasses; withDepth()/whereIsLeaf() didn't wrap identifiers; JSON-import collision keys dropped UUID/string PKs.countErrors() detects three more corruption categories: parent_bounds_mismatch, depth_mismatch, bounds_out_of_range.fixTree($anchor) rejects an unplaced anchor and reports the rows it actually walked in TreeFixResult::nodesUpdated.Pre-1.0 release: four new aggregate-subsystem features land plus correctness fixes and a small breaking-change polish pass ahead of 1.0.
Top-K aggregate (#165). New topK aggregate kind stores the K rows with the largest by value anywhere in a node's subtree as a JSON array of [source_value, by_value] pairs.
#[NestedSetAggregate(column: 'top_revenue_products', topK: 'product_id', k: 5, by: 'revenue')]
Composes with filter / filterNotNull / filterRaw and is available inside withFreshAggregates() for ad-hoc reads. Recompute-only — a single deletion can promote a row the stored list never tracked, so no signed delta exists; every contributing mutation triggers a full subtree recompute over the ancestor chain (same path the collection aggregates distinctCount / stringAgg / jsonAgg / jsonObjectAgg already use). Per-backend dispatch — PG JSON_AGG(JSON_BUILD_ARRAY(_src, _by) ORDER BY _by DESC, _src DESC), MySQL/MariaDB JSON_ARRAYAGG(JSON_ARRAY(_src, _by)) over an ORDER BY ... LIMIT k derived table, SQLite the same with JSON_GROUP_ARRAY. Tie-break by source DESC and NULL-by exclusion keep results deterministic across all four. Migration via $table->nestedSetAggregate('top_revenue_products', type: 'top_k') — nullable jsonb on PG, JSON on MySQL/MariaDB, TEXT on SQLite. K must be a compile-time constant; the stored list holds exactly K entries with no runner-up safeguard (fine for the recompute-only shape).
Lazy aggregates with TTL (#167). New lazy maintenance shape for precalculated aggregate columns. Mutations invalidate (value = NULL, <column>_computed_at = NULL) on every affected ancestor instead of recomputing eagerly; the first read past the invalidation runs freshAggregate() and stamps the companion. Optional ttl (seconds) sets a wall-clock freshness window — useful when listener aggregates have expensive PHP contributions and reads are rarer than mutations.
#[NestedSetAggregate(column: 'revenue_total', sum: 'amount', lazy: true, ttl: 60)]
#[NestedSetAggregateListener(column: 'boosted', listener: Boost::class, lazy: true)]
Aggregate::sum('amount')->lazy(60)->into('revenue_total');
ListenerAggregate::sum(Boost::class)->lazy()->into('boosted');
$table->nestedSetAggregate('revenue_total', lazy: true);
Wired into every lifecycle hook — captureAggregateDeltas / applyAggregateDeltas, applyAggregateOnCreate, applyAggregateOnDelete, applyAggregateBeforeMove / applyAggregateAfterMove, applyAggregateOnRestore — lazy defs short-circuit the eager paths and route through one LazyInvalidation::apply() UPDATE per save. Read accessor getAttribute() override checks the stamp companion, computes via freshAggregate() on miss, and writes value + NOW() back; a re-entry guard prevents recursion through listener contribution() callbacks. fixAggregates() closes with a stamp pass over the repaired subset so post-fix reads don't immediately re-recompute. Registry skips lazy candidates when picking companions for companion-derived display kinds (Avg / Variance / Stddev / WeightedAvg / Bool* / GeoMean / HarmMean) — a lazy SUM sharing a source with an AVG can't be silently adopted as its numerator. Validation throws at definition-build time for lazy: true on companion-derived display functions, fresh-read-only kinds (Median / Percentile), internal auto-promoted companions, ttl without lazy, and ttl <= 0. Known race: a read → mutation → write-back window can re-stamp a stale value; the next mutation or TTL expiry re-stales (the simpler shape was preferred over row-locking on every refresh).
Aggregate change-feed event (#166). New opt-in NestedSetAggregateChanged event fires once for every (ancestor row, aggregate column) pair whose stored value moved during a maintenance pass — lets consumers mirror aggregate values to Redis / Kafka / Reverb / search indexes without polling.
final readonly class NestedSetAggregateChanged
{
public function __construct(
public string $modelClass,
public int|string $nodeId,
public string $column,
public int|float|bool|string|null $oldValue,
public int|float|bool|string|null $newValue,
public array $ancestorChain,
public string $stage, // on_create | on_update | on_delete | move | on_restore
) {}
}
Opt-in by listener presence — the firing site short-circuits via EventDispatcher::hasListeners() when nobody is subscribed, so the package's hot path stays at its existing cost. When a listener is attached, each maintenance pass issues one extra SELECT over the targeted ancestor chain before and after the UPDATE to capture old/new values. Wired into all five aggregate-maintenance entrypoints: on_create, on_update (source-column changes), on_delete, move (separate passes for old + new chain — ancestorChain carries the relevant chain per event), and on_restore. Internal companion columns (__sum / __count auto-promotions behind AVG, Variance, WeightedAvg, etc.) are excluded — only user-declared aggregate columns produce events.
Listener aggregate hardening (#170). Three independent improvements to the #[NestedSetAggregateListener] surface, shipped together because the docs rewrite spans all three.
fixAggregates() is now O(N), not O(N²), for listener columns. Replaces the outer × inner containment scan in ListenerMaintenance::fixListenerAggregatesPhp() and aggregateErrorsForListeners() with a single stack-based DFS over a lft-sorted node list, one pass per definition. New ListenerAccumulator holds per-operation running state. Repair time on million-row forests drops from quadratic to linear.filter: / filterNotNull: on the listener attribute. Mirrors the SQL aggregate parameter shape; the fluent ListenerAggregate builder gains the matching chained methods. Filter watch columns join the listener's own watchColumns() so filter-column mutations re-trigger maintenance. Auto-promoted companions inherit the parent filter. filterRaw: is deliberately omitted — listener mode has no SQL evaluation path.Variance, Stddev, GeometricMean, HarmonicMean. ListenerAggregateDefinition gains a sourceTransform field; registry auto-promotion creates companion definitions with the right transform per CompanionSpec (Square for __sum_sq, Ln for __sum_log / __count, Recip for __sum_recip / __count). Display values are computed in PHP via the same textbook formulas as the SQL form, with matching domain handling for the means.Documentation updated accordingly — docs/aggregates/listeners.md gains a Filters section and a Listener variance, stddev, geometric mean, harmonic mean section, and the fixAggregates() is O(N²) / Filters are encoded in the listener itself caveats are gone.
Type-preserve MIN/MAX reads (#171). Five reads of MIN/MAX columns and their source attributes in HasNestedSetAggregates used Numeric::asIntOrZero, truncating decimal-cast values before they were fed into the recompute filter or pushed up as an extreme. A decimal(10,2) source storing 9.99 became 9; the cheap-skip WHERE stored = 9 then matched no ancestor and the recompute silently no-op'd, leaving ancestors stuck at the deleted holder's value. Sister-listener paths already used asNumericOrZero — same change applied across the delete cheap-skip, move cheap-skip, Max/Min update-path source reads, and the insert-path source capture.
Guard unplaced mutation targets (#171). HasTreeMutation::callPendingAction now rejects mutations whose target row exists on disk with lft = rgt = 0 (raw insert, fixture seeder, recovery from prior corruption). Previously those zero bounds fed into makeGap(0, 2) — shifting every row in the scope up by 2 and leaving the unplaced target at (2,2) with the new node at (0,1), outside its purported parent. Mirrors the existing HasSubtreeClone::guardCloneDestination check so all destination-style APIs treat unplaced targets consistently. Restricted to $target->exists so the existing "no primary key" rejection for unsaved targets stays in place.
Two small surface changes from the 1.0 polish pass (#168):
getNodeHeight() (HasNodeInspection). Was [@deprecated](https://github.com/deprecated) with the comment "Will be removed before 1.0". Callers should use getSubtreeSize().moveToSiblingPosition() throws LogicException, not OutOfRangeException, for out-of-range positions. Unifies with every other sibling-mutation primitive, which already throws LogicException for the same shape of programmer error.docs/tree-operations/cloning.md) and lazy aggregates (docs/aggregates/lazy.md); event catalogue gains SubtreeCloned; exporters page gains a fromJsonTree() section; config reference gains the materialised_path block; small worked examples land across 15 pages via the ns-tree widget.MaterialisedPathTooLongException etc.) replaced with the real class names (PathTooLong, InvalidPathSegment, DuplicatePathSegment) — users following the old docs would have hit class-not-found when catching.Cross-backend mutation gaps in the aggregates strategy selector and NestedSetScopeResolver closed via 25+ verified kills (#175). Includes a new CountArea fixture covering Count(col)->filter for both SQL and listener paths, and exception-message pinning across all 40 Concat / ConcatOperandRemoval mutants in NestedSetAggregate.php. Test-only — no src/ changes.
Pre-1.0 release: an opt-in materialised-path feature lands on every NodeTrait model. A model declares one or more denormalised path columns via #[NestedSetMaterialisedPath]; the package keeps each column coherent with the tree on every mutation — insert, update, move, rename, bulk insert, subtree clone, soft-delete restore, and fixTree. Reorder leaves paths untouched by construction. No breaking changes from v0.19.x — attribute-only models that don't declare any path are unaffected; the registry returns [] and the listener short-circuits.
Materialised path columns (#164). Each declaration names a column, a segment source (key / attribute / slug / a closure via the materialisedPaths() method form), and per-column formatting (separator, wrap, maxLength, rejectSeparatorInSegment, uniquePerParent). A single model can carry several paths at once — e.g. url_path = '/electronics/laptops/' for URLs alongside crumb_path = 'Electronics > Laptops' for breadcrumbs — each maintained independently so a rename touching one source attribute writes only the columns whose value actually changes. Querying is pure Eloquent (where('url_path', 'like', $prefix.'%')); the column is just a string.
Defaults flow through a five-layer resolution chain — per-path explicit → #[NestedSetMaterialisedPathDefaults] class attribute → config('nestedset.materialised_path.class_defaults.'.$class) (exact FQCN, no is_a walk) → config('nestedset.materialised_path.defaults') → package fallback. MaterialisedPathRegistry::for($class) resolves and caches per FQCN; forgetCache() exists for test isolation.
Lifecycle hooks the existing saving / saved events. Per-row INSERTs write inline; renames and subtree moves issue one subtree-rewrite UPDATE per changed column — MySQL/MariaDB CONCAT() + SUBSTRING(col, n), PostgreSQL/SQLite || + SUBSTR(col, n). (SQLite uses || because CONCAT() only shipped in 3.44; PG uses SUBSTR instead of SUBSTRING ... FROM ? because positional ? inside SUBSTRING FROM defeats PG's parameter-type inference and silently NULLs the column.) Key-dependent paths (key: true or closure with ->dependsOnKey()) on autoincrement rows wait for saved and write via one targeted UPDATE — the autoincrement key isn't known pre-INSERT.
Six new typed exceptions — EmptyPathSegment, InvalidPathSegment, DuplicatePathSegment, PathTooLong, NonDeterministicPathSegment, MaterialisedPathConfigurationException — each carrying the column name and model class so multi-path failures are diagnosable. Per-parent uniqueness uses an indexed equality check on the declared column; collation semantics live in the segment builder (case-insensitive callers lowercase inside MaterialisedPath::from(...)). A determinism guard gated on APP_DEBUG double-calls the segment builder per save and throws if the two calls disagree — catches request() / auth() / now() in builders before the divergent values reach disk.
Repair surface: Model::fixMaterialisedPaths(?column, ?anchor) walks parent_id in PHP and rebuilds every declared column (or just one) via one batched UPDATE per column. fixTree() runs the path rebuild as its final step so a single call repairs structure + aggregates + paths; TreeFixResult now carries a $materialisedPathsRepaired map of column => row-count.
Bypass: Model::withoutMaterialisedPathMaintenance(Closure) short-circuits both listeners with reentrant depth counting. The supported pattern for very large bulk renames is bypass + a follow-up fixMaterialisedPaths(); no async-by-default job ships (the user can dispatch the repair to a queue themselves).
Integration with previously-landed features: reorder (#161) leaves all declared columns bit-identical because the CASE-WHEN UPDATE bypasses Eloquent saves; tree-diff apply (#162) dispatches through normal save() / appendToNode() so path maintenance runs naturally; clone (#163) extends the $transform reserved-column rejection list to include path columns and explicitly recomputes paths over the cloned subtree after the bulk insert commits (the clone path uses Model::withoutEvents() so the saving listener never fires for cloned rows).
53 new tests across tests/Unit/MaterialisedPath/ and tests/Feature/MaterialisedPath/ covering value-object semantics, attribute/method-form resolution, the five-layer defaults chain, registry caching, insertion, multi-path independence, move/rename subtree rewrite, validation (empty / separator-in-segment / length / duplicate), key-dependent two-phase writes, closure source, bypass + repair round-trip, clone, scoped isolation, reorder no-op, determinism guard (throw and pass paths), and the parent-relation eager-load short-circuit. New docs page at docs/tree-operations/materialised-paths.md; corruption taxonomy row at docs/maintenance/corruption.md.
Pre-1.0 release: three opt-in mutation features land on every NodeTrait model — an atomic CASE-WHEN sibling-reorder primitive, a single-transaction subtree clone, and a typed tree-diff value object paired with a JSON-tree importer. No breaking changes from v0.18.2.
Sibling reorder primitive (#161). HasTreeMutation gains a reorderChildren(array $idsInOrder) primitive so drag-and-drop UIs can re-shuffle a sibling group in a single atomic CASE-WHEN UPDATE instead of N insertAfterNode() calls. The existing lft column remains the authoritative sibling sequence — no new column, no second source of truth.
$parent->reorderChildren($ids) — strict ID set; missing, extra, or duplicate keys throw InvalidSiblingOrderException. Accepts IDs or models via a normaliser.$parent->reorderChildrenBy(string|Closure $key) — sugar that sorts siblings by a column name or callback.$child->moveToSiblingPosition(int $position) — 1-indexed, matches up() / down().Model::reorderSiblings($parent, $ids) — static wrapper. New event SiblingsReordered { parent, idsInOrder, rowsAffected }.One CASE-WHEN UPDATE per reorder: for every row strictly between parent.lft and parent.rgt, add the containing sibling's delta (new_start − old_start) to both lft and rgt. depth and parent_id are unchanged because siblings stay at the same depth. Identity reorders are a no-op — supplying the current order skips the UPDATE entirely. Aggregates are stable by construction — reordering doesn't change ancestry, so the raw UPDATE goes through TreeMutationBuilder and the aggregate listener never fires. Scoped trees — the UPDATE predicate includes the parent's full scope tuple; other partitions are untouched. ReorderScopedTest::test_multi_scope_reorder_does_not_leak_into_other_partitions builds 4 partitions across tenant_id × site_id and asserts every other partition is bit-identical after a reorder; this closes the known .= → = scope-loop mutation-escape gap. New docs page at docs/tree-operations/reordering.md; ReorderFuzzerTest runs 1000 assertions over random permutations.
Subtree cloning (#163). New HasSubtreeClone concern adds cloneSubtreeTo($parent, $position, $transform, $includeTrashed), cloneSubtreeAsRoot(...), and a static Model::cloneSubtree(...) convenience. Builds the same nested-array payload bulkInsertTree already accepts — so it inherits autoincrement/UUID parent-id reconciliation, scope copy, and one deferred aggregate recompute. One transaction; rollback on any failure leaves no half-cloned state and needs no fixTree follow-up. New SubtreeCloned event (single end-of-clone signal — per-row Eloquent events are suppressed via withoutEvents) and InvalidCloneTargetException for "destination is in source's own subtree" failures.
Column-by-column behaviour: primary key regenerated; structural columns (lft / rgt / depth / parent_id) regenerated and $transform setting them throws; scope columns inherited from destination parent (or source for asRoot) and $transform setting them throws; timestamps refreshed to now (overridable); soft-deleted rows always land live, even with includeTrashed: true; aggregate columns zeroed then filled by deferred recompute; everything else verbatim from $source->getAttributes(). Scoped-model cloneSubtreeAsRoot seeds under the source row then immediately makeRoot-s, kept inside the clone's outer transaction so atomicity holds. 26 new tests under tests/Feature/Clone/ cover shape, depth math, parent-id wiring, UUID + autoincrement keys, $transform (incl. structural-column rejection and exception propagation), includeTrashed true/false, scope inheritance + cross-scope rejection, self-clone + own-subtree rejection, aggregate recompute, replicate parity on a leaf, event emission, per-row event suppression, and transaction rollback.
Tree diff + JSON tree import (#162). New Vusys\NestedSet\Diff\TreeDiff value object with TreeDiff::between($before, $after) and TreeDiff::apply() — remove → add → move → modify under one transaction, deferred aggregate maintenance, cycle detection, dry-run mode, and invert(). Sibling reorder surfaces as Moved with fromParent === toParent. Paired with a fromJsonTree() static on HasTreeExport (nested + flat shape autodetection, strict/lax modes, per-row $transform, includeKeys with collision detection, aggregate columns recomputed on import). Six new exceptions — DuplicateNodeIdentity, DanglingParent, MissingParent, CyclicMove, InvalidJsonTree, JsonImportKeyCollision. 43 new tests across tests/Unit/{Diff,Import} and tests/Feature/{Diff,Import}. The HasNestedSet contract intentionally omits the trait's mutation surface (bulkInsertTree, withDeferredAggregateMaintenance, appendToNode, makeRoot); the applier and importer dispatch through is_callable([class, method]) to stay compatible with that decision. apply() currently inserts adds per-row via appendToNode — lifting that to bulkInsertTree is a follow-up.
Docs-only patch release. No src/ changes — README and docs/index.md were workshopped so the headline preview is more honest about what the package does. All v0.18.0/v0.18.1 application behavior is preserved.
Running example switches from Category/products to BudgetItem/cost so each declared aggregate maps to a natural business question (`cost_total` = total spend, `item_count` = line items, `avg_cost` = avg line cost, `biggest_item` = max, `recurring_total` = filtered SUM). "Average product count per category" felt forced; "average cost per line item" doesn't.
Hand-drawn ASCII tree comment replaced with a real BudgetItem::toAsciiTreeForest(new AsciiOptions(label: ...)) call. The rendered ASCII below the call is what the exporter would actually print — readers see the API surface they'd use, not a fiction.
Demo tree grows from 2 to 3 levels (Engineering → People → Salaries/Bonuses, Engineering → Tools → SaaS/Hardware, Operations → Office). The update mutation now cascades through two ancestor levels (Bonuses → People → Engineering); the move re-parents a whole 3-node subtree atomically. The tree renders before AND after so the move's effect on both old and new ancestors is visually obvious.
Inlined node lookups as BudgetItem::query()->where('name', '=', '...')->first() at each call site, instead of conjuring undefined \$bonuses / \$tools / etc. The snippet now stands alone without imagined prior context.
getSubtreeSize() example replaced with getDescendantCount(). The previous example was confusing: getSubtreeSize() returns the lft/rgt slot count (rgt - lft + 1 = 2N), not the node count, so a 2-node tree returned 4. Swapped for the descendant count most readers actually want, with a comment that spells out the +1-for-self adjustment.
Docs link cleanup: dropped the dead vs. kalnoy/nestedset entry (docs/reference/comparison.md doesn't exist), added the Glossary page from v0.18.1, added a new Internals line linking to Architecture Overview.
docs/index.md ns-tree interactive widget now uses the same budget tree as the code snippet that follows it ({cost=NNN} annotations roll up automatically). The interactive widget, the prose, and the code snippet are now one consistent dataset — three lenses on the same tree.
Patch release. No src/ changes — docs build improvements, a glossary page, sub-heading restructure, plus a round of CI workflow improvements and a composer infection developer-tooling script. All v0.18.0 application behavior is preserved.
<h2>–<h4> and prefixes section numbers (1., 1.1, 1.1.1) automatically, so every page gets a sidebar TOC for free instead of curating one per page. Adds a Glossary page covering acronyms, encoding, traversal, aggregates, and repair. Structural sub-heading pass across maintenance / exporters / walking / inserting / bulk-insertion / soft-deletes docs hoists limitations / edge-cases / pitfalls sections out of bullet lists into real H3/H4s.composer infection (#158). Infection is now a real require-dev dependency (infection/infection: ^0.33) instead of an ad-hoc workflow install. A new composer infection script runs it with --threads=max against whatever DB_CONNECTION is set, so the binary CI runs is the same binary contributors run locally against any backend. Dropping the workflow's Laravel-12 pin is the side effect of the bump — 0.33.x supports Laravel 13's symfony/console ^8, so the mutation matrix now runs against the same Laravel version as the rest of CI.full Infection job's rest shard had grown past the runner's working budget; split into 7 LOC-balanced shards (concerns / query / agg-core / agg-registry / walker-export / testing / rest, each 1.3k-5.4k LOC). Includes a previously-uncovered shard for src/Walker/ added in v0.18.0.jlumbroso/free-disk-space to free ~25GB of unused preinstalled tooling at the top of the full job (Android SDK, .NET, Haskell). Tool-cache and large-packages are kept off — both break setup-php and are not worth their disk savings.[MON] sampler streams free -h / top-RSS to stdout every 5s during the infection run, so the data survives even when the runner agent is host-SIGTERMed (an if: always() post-mortem step does not — the runner-shutdown cascade marks subsequent steps as cancelled). The sampler caught a single PHP process climbing to 14.5 GiB on testing/pgsql: a runaway mutant of BuildsNestedSetTrees. Three layered fixes land together — the testing shard caps at --threads=2, (testing, pgsql) caps further at --threads=1 via a per-cell matrix override, and PHP's memory_limit=2G is enforced via setup-php's ini-values so any runaway mutant fails cleanly via PHP OutOfMemoryError (infection records it as killed-by-error — exactly the right outcome) instead of taking down the runner.Pre-1.0 release: two opt-in public-API features land on every NodeTrait model — an in-memory subtree walker and a Laravel-factory mixin for building real fixture trees in one call — alongside a 4-round docs/code coherence audit and a second mutation-testing gap-closure pass against the v0.17.0 union report. No breaking changes from v0.17.0.
In-memory subtree walker (#153). HasTreeWalk adds walk() / dfs() / dfsPostOrder() / bfs() / flattenedSubtree() to every NodeTrait model — a generator-based traversal over an already-loaded subtree with no nested-Collection allocation. The walker is a pure consumer of in-memory data: with no $subtree argument it reads $this->descendants; with neither available it throws UnloadedSubtreeException (a feature test asserts DB::listen fires zero queries on walk() over a loaded relation). The implementation lives in four value objects under src/Walker/:
SubtreeWalker — builds byKey: id => Model and childrenByParentKey: parentId => list<id> indexes in O(N) on construction, sorts children by lft defensively so callers passing reordered collections still get deterministic walks, and uses an explicit task stack for DFS so deep trees do not blow PHP's call stack. Implements Countable; maxDepth() and leafCount() companions share a single cached index pass.WalkContext — readonly value object passed as the visitor's second argument: depth (relative to the walk root, not the absolute depth column), parent, siblingIndex, siblingCount, derived isFirstSibling / isLastSibling, and a lazy pathToRoot() that walks up via the index on first call. Future affordances become new fields/methods, not new positional args.WalkFilter — final readonly (maxDepth, visitable, includeRoot) triple with depth() / where() / compose() named constructors and an andThen() instance combinator. The same filter plugs into every exporter's option object (AsciiOptions, MermaidOptions, DotOptions, JsonOptions); AsciiOptions::maxDepth composes into the filter so the existing knob and the new one prune identically. TreeExporter resolves the filter to a visible-key set once per render and applies it uniformly across all four formats.WalkSignal — two-case enum (SkipSubtree, Stop) returned by visitor closures. Skip is honoured by pre-order DFS and BFS, ignored by post-order. Returning null (or void) continues.HasTreeRepair::fixTree (walkAssignPositions) and HasBulkInsert::bulkInsertPlan were deliberately not refactored onto the walker — their walks operate on parent_id graph maps and nested-array payloads respectively, not loaded Model collections, so they don't share SubtreeWalker's data shape. The risk on the critical maintenance path outweighed the DRY win.
Factory tree builder trait for tests + seeders (#152). Vusys\NestedSet\Testing\BuildsNestedSetTrees is a Laravel factory mixin that returns a real tree in one call, backed by bulkInsertTree — depth-3 branching-5 (156 nodes) costs 3 statements instead of 156. Two shapes cover the common cases:
tree() — uniform / per-depth-array / closure branching (tree(depth: 3, branching: 5), or branching: [3, 4, 2], or branching: fn ($depth, $parent) => ...).treeFromShape() — explicit nested-array form.previewTree() — returns the resolved payload without persisting (round-trips through treeFromShape).The per-row hook receives (depth, siblingIndex, parentAttrs) for tenant inheritance, depth-keyed weights, etc. Upfront pre-checks reject trashed parents, cross-scope grafts, and missing label columns with diagnostic messages rather than letting DB constraints fire downstream. make() is rejected on tree shapes (no persistence ⇒ no lft/rgt); afterCreating fires per-row in DFS pre-order with an opt-out for million-row seeders. Aggregates recompute correctly via the existing withDeferredAggregateMaintenance flow inside bulkInsertTree. Sequence callbacks cycle in DFS order. New docs page at docs/reference/factories.md; covered by 11 pure-logic tests over TreeBuilderShape plus 30 DB-backed feature tests across four fixture factories.
Close further mutation-testing gaps from the v0.17.0 union report (#151). Six follow-up gaps from the 26664389389 union run, triaged from escapedEverywhere / uncovered data and verified-killed locally by mutate → run → restore before each test was added. The source diff is a single additive extraction (FreshAggregateProjector::mysqlVersionSupportsLateral() pulled out for testability); everything else is fixtures + tests.
$sql .= " AND {$col} = ?" accumulator loops at AggregateSqlFragments:567 (correlated raw-filter read path) and AggregateDiffer:465 (chain-shape detector) iterated once and looked identical to a single assignment. New MultiScopedBranch fixture (tenant_id + site_id) pins both sites; production impact if broken would have been cross-tenant aggregate leakage on multi-scope models.BitwiseMaintenanceTest only covered the PHP-delta path, so the BIT_OR / BIT_AND / BIT_XOR PDO UDA registrations were never end-to-end exercised on SQLite. New BitwiseFreshReadTest drives withFreshAggregates and aggregateErrors against BitwiseArea, forcing SqliteBitwiseAggregates::ensureInstalled() and the three UDA folds.mysql:8.0 service only exercised one branch of the 8.0.14 cutoff (18 uncovered mutants). New pure-unit test with 20 seeded version strings (null, malformed, MariaDB family, MySQL either side of 8.0.14).ListenerMaintenance was never executed. Added descendant_fire_count to Monster + tests covering fresh read, fixAggregates, and aggregateErrors. The > → >= GreaterThan mutants are equivalent under the nested-set unique-lft invariant; the new tests still pin the exclusive arm's behaviour.freshListenerAggregate() and applyListenerOperation() each have 12 shared cases; only Variance + Median were covered. UnsupportedOpMonster now declares one column per unsupported op; the rewritten test drives freshAggregate() per row with arm-specific expectExceptionMessageMatches patterns, pinning MatchArmRemoval and exception-message Concat mutants on both sites.(float) / (int) cast removals on string-typed numeric sources survived because every existing test fed native ints/floats. Stringy-numeric data-provider rows for sum/min/max/bit_or/bit_and/bit_xor/weighted_avg/variance/geometric_mean make Min/Max/Bit* cast removals throw TypeError via downstream strict return types. Sum/WeightedAvg cast removals remain equivalent (PHP coerces in arithmetic).Docs/code correctness & coherence audit, 4 rounds (#150). Cross-checked every claim in the README, docs/, and source-level docblocks against the code. Where they diverged, the docs won — except a few cases where the code's own docblock was wrong:
TreeAggregateListener::contribution() docblock said "null is treated as 0 for Sum". Value-equivalent for Sum itself, but for Count / Avg / Min / Max listeners null excludes the row entirely while 0 still counts. Rewritten; docs/aggregates/recipes.md corrected to match.AggregateMaintenanceFailed::$stage docblock listed 'recompute' as a valid value, but NodeTrait::runAggregateHook() only dispatches 'capture' | 'apply' | 'on_create' | 'on_delete' | 'on_restore'. Updated and back-linked to the dispatcher.docs/aggregates/quantiles.md filterRaw example used a ? placeholder + watches array. The package inlines filterRaw SQL verbatim with no parameter binding (FilterPredicate::raw) — copy-pasting that example would have produced a SQL syntax error. Replaced with a literal-value form and cross-linked to the security note in filtered.md.docs/maintenance/corruption.md §7 diagnostic SQL — correct for unscoped, hard-delete-only tables but missing two adjustments: the orphan query needs scope-equality on the JOIN (otherwise a child whose parent_id matches a row in another scope joins successfully and masks the orphan — what orphanQuery() guards against internally), and the aggregate-drift query needs WHERE d.deleted_at IS NULL on the inner side for SoftDeletes tables (otherwise it over-counts trashed descendants and reports false drift).docs/reference/events.md — five aggregate-maintenance event rows had inconsistent anchorId coverage in their payload columns; made it consistent across the table.docs/reference/production.md — the MariaDB optimizer_switch note listed ->cursor() as covered by runSelect(). Verified against vendored Laravel: Builder::cursor() calls Connection::cursor() directly, bypassing runSelect(). Updated to list actually-covered methods (get / first / paginate / chunk — the last two go through get()), group cursor() with the bypass methods, and suggest chunk() as the workaround for streaming reads on MariaDB.CLAUDE.md — three structural inaccuracies in the Query-layer section fixed: TreeBaseQueryBuilder is not the parent of the others (different hierarchy — extends Laravel's Query\Builder); phantom TreeAggregateBuilder removed (no such file post-§6.5 split); TreeExpression is a 27-line Expression wrapper, not a backend-dispatching SQL generator (per-driver dispatch lives in AggregateSqlFragments.php / FreshAggregateProjector.php). NodeTrait concern count corrected (8, not 7 — HasTreeExport was missing).docs/tree-operations/inserting.md "refresh footgun" reframed — the trait freshens the target's bounds via freshBoundsOf() so the mutation is safe; the real failure mode is subsequent reads off the stale in-memory model.stubs/Blueprint.php deleted — orphan file, not referenced from phpstan.neon, composer.json, or any IDE config; redeclared Illuminate\Database\Schema\Blueprint in the real namespace so loading it at runtime would collide; listed only 2 of 4 package macros. phpstan-bootstrap.php already covers all four via Blueprint::macro() for static analysis.Pre-1.0 release: mutation testing now runs across all four database backends, the gaps that surfaced are closed with test-only changes, and the docs site gains a contributor-grade Internals walkthrough plus an interactive nested-set tree widget. No breaking changes from v0.16.0.
shard × db matrix — four LOC-balanced non-overlapping src/ shards (concerns, query, aggregates, rest) × four backends — unioned by .github/scripts/merge_infection.py. A mutant counts as escaped only if it survived in every backend that covered it; killed if any backend caught it. The aggregate job also flags backend-divergent mutants (killed on some, escaped on others — a backend-specific assertion gap), publishes the union MSI to Stryker, and uploads the per-mutant report as an artifact. Per-cell distillation reduces reports (which embed full source per mutant) to compact status records before upload. Wall-clock stays roughly one slow shard instead of 4× the SQLite baseline.Close 27 real mutation gaps across four files, no source changes (#148). Test-only — every mutant was verified killed locally via mutate → run → restore. Coverage map:
| File | What the new tests pin | Mutants |
|---|---|---|
ChainFoldAccumulator |
Float casts, harmonic-mean null, bool coercion | 9 |
Aggregate |
stringAgg/jsonAgg/jsonObjectAgg limit boundaries, orderBy coalesce precedence, array-source inclusive flag |
9 |
AggregateRegistry |
Listener AVG companion auto-promotion (internal flag), user-declared-companion dedup, method-override resolver returns-all | 4 |
FreshAggregateProjector |
Scope predicate (scalar + correlated + quantile subqueries), soft-delete predicate, filter predicate, listener-only early return | 5 |
Notable correctness detail: the scope-predicate tests build two tenants whose lft/rgt ranges deliberately overlap (each saveAsRoot() starts a fresh 1..N range per partition), so dropping the scope clause makes a fresh read bleed across tenants — that's what makes the predicate observable and the mutants killable. Also hardened a real test-helper weakness in ChainFoldAccumulatorTest: assertEqualsWithDelta($expected, null) silently passes because null coerces to 0 in the subtraction, so a number→null mutation escaped — added an explicit assertNotNull guard.
Add an Internals section to the docs (#149) — a seven-page, contributor-grade source walkthrough anchored to v0.13.0-26-gca9b1fb:
internals/architecture.md — 8-concern NodeTrait composition, layer map, bootNodeTrait() lifecycle hooks, Blueprint macros, load-bearing design decisions.internals/nested-set-model.md — lft/rgt/depth/parent_id invariants, the NodeBounds value object, why parent_id is canonical.internals/mutation-engine.md — queue-then-dispatch (PendingOperation), makeGap / closeGap, the moveNode CASE WHEN arithmetic with a worked numeric example, positionAt and the aggregate seams.internals/query-engine.md — the BETWEEN scope table, custom DescendantsRelation / AncestorsRelation, TreeExpression, and the MariaDB optimiser hook on TreeBaseQueryBuilder.internals/aggregate-maintenance.md — delta vs recompute vs companion-derived strategies, lifecycle hook ordering, the asNumericOrZero vs asIntOrZero type-preservation footgun, soft-delete snapshot semantics, deferred maintenance.internals/repair.md — countErrors corruption queries, the iterative-DFS rebuild (walkAssignPositions), chunked bulk CASE WHEN writes, subtree-size delta shift, scope guard.internals/concurrency.md — auto-transactions, FOR UPDATE row locks (incl. the PostgreSQL makeRoot aggregate-lock quirk), aggregate_locking and the recompute race, EventDispatcher gating.Interactive nested-set tree widget + site design pass (#147). Embeddable, dependency-free tree widget for the docs site:
```ns-tree fenced block — indentation is the hierarchy.lft/rgt/depth with the same DFS the package uses.{products=37}) becomes an aggregate source value and rolls up every ancestor as a SUM.WHERE lft BETWEEN … AND … query (and matching SUM when a metric is present).window.NestedTree, with a flat TreeData shape ({ id, parentId, name, lft, rgt, depth, value, rollup, chips }) — so a future package-driven Laravel demo app can produce the identical shape from Model::defaultOrder()->get() and reuse the same renderer. Partial-subtree fetches render correctly (a node whose parentId is absent is treated as a root).Site design pass adds: landing hero on the intro page, GitHub-style callouts (> [!NOTE] / TIP / IMPORTANT / WARNING / CAUTION) expanded in build.php with the body rendered as Markdown, code-block copy buttons + language labels, and a nested "On this page" TOC with rAF-throttled scroll spy.
⚠️ Breaking change — namespace re-shape (#141)
The flat
Vusys\NestedSet\Aggregates\*andVusys\NestedSet\Events\*namespaces are split by responsibility, and two user-implementable interfaces are promoted toContracts\(Laravel convention). Noclass_aliasshims —usestatements andEvent::listen(::class)calls must be updated. Pre-1.0 polish-phase window.Aggregates — most-likely-imported renames (full mapping in the PR #141 body):
Before After Aggregates\AggregateDefinitionAggregates\Definitions\AggregateDefinitionAggregates\ListenerAggregateDefinitionAggregates\Definitions\ListenerAggregateDefinitionAggregates\CompanionSpecAggregates\Definitions\CompanionSpecAggregates\CompanionSourceOrigin/CompanionSourceTransformAggregates\Definitions\…Aggregates\FilterPredicate/FilterPredicateKind/FilterValueQuoterAggregates\Filters\…Aggregates\AggregateRegistryAggregates\Registry\AggregateRegistryAggregates\AggregateSqlEmitter/DerivedAggregateFragments/VarianceSqlFragments/SqliteBitwiseAggregatesAggregates\Sql\…Aggregates\TreeAggregateListenerContracts\TreeAggregateListenerAggregates\AggregateDefinitionContractContracts\AggregateDefinitionContractPublic API roots —
Aggregate,ListenerAggregate,AggregateFunction,AggregateFixResult— stay atVusys\NestedSet\Aggregates\.Events — by lifecycle phase:
Sub-namespace Events Events\Mutation\NodeMoved,NodePromotedToRoot,NodesSwappedEvents\Subtree\SubtreeMoving,SubtreeMoved,SubtreeForceDeleting,SubtreeForceDeletedEvents\SoftDelete\SubtreeSoftDeleting,SubtreeSoftDeleted,SubtreeRestoring,SubtreeRestored,SoftDeleteMarkerCapturedEvents\BulkInsert\BulkInsertTreeStarting,BulkInsertTreePlanned,BulkInsertNodeSaved,BulkInsertTreeSaved,BulkInsertTreeCompletedEvents\Aggregates\NodeAggregatesRecomputed,AggregateDriftDetected,AggregateMaintenanceFailed,DeferredMaintenanceStarting,DeferredAggregateMaintenanceCompleted,FixAggregatesChunkCompleted,FixAggregatesCompleted,FixAggregatesJobDispatchedEvents\Repair\FixTreeCompleted,TreeIntegrityCheckedEvents\Diagnostics\ScopeViolationDetected
Events\EventDispatcherstays at the namespace root.Upgrade: ripgrep
use Vusys\\NestedSet\\Aggregates\\anduse Vusys\\NestedSet\\Events\\across your app and rewrite to the new FQCNs. The new locations are searchable in the docs site under §6.1 and §6.2.
Pre-1.0 polish release: the round-3/4/5 sweeps. One BC-affecting namespace reshape (callout above), three internal-only refactors that split large files into focused classes by responsibility, a doc-coherence pass covering the M3–M5 features and the NodeTrait surface, and a test-reshuffle that mirrors tests/ to src/ and fills genuinely-reachable coverage gaps.
Aggregates and Events into sub-namespaces (§6.1, §6.2) (#141). BC — see callout above.AggregateRegistry *CompanionsFor() dedupe → shared findFirstCompanion(); TreeAggregateBuilder chain-fold tower → ChainFoldAccumulator value object; HasNestedSetAggregates numeric helpers → Vusys\NestedSet\Aggregates\Numeric; HasTreeRelations::requireTreeBuilder() helper.TreeAggregateBuilder (2 710 LoC) into four focused classes under Query\Aggregates\ (§6.5) (#143) — AggregateValueComparator, AggregateSqlFragments, AggregateDiffer, FreshAggregateProjector. Pure motion; the original class is deleted (internal-only).Vusys\NestedSet\Aggregates\Listeners\ListenerMaintenance (§6.4) (#144) — HasNestedSetAggregates trait drops 341 LoC; mutation hooks, public maintenance API, anchor/scope plumbing stay in the trait and delegate.addToAssertionCount(1) placeholders with real before/after snapshots; pins the documented countErrors() cycle-detection gap so it fails loudly if ever fixed. New coverage for cross-scope parent_id corruption (ScopedCorruptionRecoveryTest), Median/Percentile × fixAggregates interaction, MeanArea structural moves, WeightedAvg edge cases (weight→0, negative weight, zero-total NULL), FixAggregatesJob serialisation + failure, bulkInsertTree × withDeferredAggregateMaintenance nesting, and parallel appendToNode on the same parent (AppendToNodeConcurrencyTest, multi-writer-CI only). Three marathon scripted-scenario suites broken into focused regression-class names sharing seed helpers.tests/ to mirror src/ (#145). 100+ files reorganised across three independently-green commits: Unit/ re-nested into Aggregates/{Definitions,Filters,Registry,Sql}, Attributes/, Events/, Query/; Feature/ gains Mutation/, SoftDelete/, BulkInsert/, Events/, Scoping/, Fuzzers/; Feature/Aggregates/ further split into Maintenance/, Integrity/, Functions/, Listeners/, Repair/, Collections/. The EdgeCasesTest catch-all decomposed so each concern owns its edge cases. PSR-4 path→namespace rewrite verified as a no-op on the pre-refactor tree before any moves landed.ChainFoldAccumulator + Aggregate coverage + data-provider adoption (#146). SQLite line coverage 86.55% → 90.01% (1 321 → 1 454 tests). New ChainFoldAccumulatorTest covers the PHP chain-fold fast path for every reachable kind (Sum/Count/Min/Max/Bit*/Avg/Variance/Stddev/WeightedAvg/BoolOr/BoolAnd/geometric+harmonic mean) plus companion source-transforms; Aggregate factory coverage 76% → 99.7%; DerivedAggregateFragments 62% → 100% via snapshot tests; NestedSetAggregate attribute validation paths driven by a DataProvider; new ScopedArea fixture (the only one combining partitioned trees with maintained aggregates) lifts the NestedSetScopeResolver + scope-predicate paths.Cover M3–M5 + rewrite setup / maintenance / deferred sections (#138). New docs/aggregates/means.md (geometric / harmonic, positivity & non-zero constraints, allowNonPositive(), the EXP(Σ LN(x) / n) precision caveat). New docs/aggregates/quantiles.md (median / percentile / percentiles([...]) / quartiles() — fresh-read-only constraint, linear-interpolation semantics, per-backend SQL shape table). Replaces the previous SUM/COUNT-only caption in setup.md with a complete 14-row type: reference (display column shape, null/default convention, auto-allocated companions, dropNestedSetAggregate('col', type: …) requirement). maintenance.md cost matrix replaced with families table covering delta-maintainable / companion-derived / extremum-recompute / chain-recompute / bitwise mixed / fresh-read-only. fix-aggregates.md expands withDeferredAggregateMaintenance with the explicit signature, scoped-model anchor requirement, re-entrancy counter, failure-safety, and observability events.
Round-3 coherence pass — pitfalls callout, soft-deletes rewrite, exception taxonomy (#139). 15 docs-only fixes folded into one PR. NodeTrait surface in model-setup.md updated to include bulkInsertTree, moveTo/moveBefore/moveAfter, isSiblingOf, prevSibling/nextSibling, and the HasTreeExport concern. New "Refresh after mutating a child" callout — the package only refreshes the target of a mutation, the parent reference you held goes stale (the #1 footgun). Exception taxonomy split into LogicException (programmer error) vs RuntimeException (data-state); CorruptTreeException and AggregateSourceConstraintViolationException added (were missing). soft-deletes.md rewritten from a 15-line stub to cover cascade lifecycles, microsecond timestamp matching (DATETIME(6) caveat), aggregate semantics on cascade, and scope interaction. Listener AVG companion migration example now shows the float-returning case as a decimal column (bigint default silently truncated). queries.md ordered-output examples now call defaultOrder(). bulk-insertion.md notes the silent scope-column override.
Pre-1.0 release expanding the aggregate vocabulary with statistical, weighted, boolean, bitwise, and quantile families on top of new companion-column infrastructure. No breaking changes from v0.14.0 — existing aggregate kinds, attribute syntax, and maintenance semantics are unchanged.
Companion-column infrastructure (M0) (#125). AVG's Sum + Count companion-promotion logic generalised into a per-function CompanionSpec facility so the new maths kinds ride on shared machinery. AggregateFunction::companionSet(): list<CompanionSpec>; AggregateRegistry::autoPromoteCompanions() replaces the AVG-only path; the nestedSetAggregate('foo', type: 'avg') Blueprint macro now allocates the display column plus companions in one call. No new aggregate kinds in M0 itself — pure infrastructure.
Variance and standard deviation (M1) (#135). Aggregate::variance(source, sample: false) / Aggregate::stddev(source, sample: false) (both population and sample variants), mirrored as variance: / stddev: / sample: on #[NestedSetAggregate]. Backed by a Sum/SumSq/Count triple — a new CompanionSourceTransform enum lets the SumSq companion sum source * source. The in-UPDATE SET clause uses the textbook E[X²] − E[X]² form (new VarianceSqlFragments helper), with a CASE-zero clamp around SQRT so tightly-clustered data doesn't break Postgres on floating-point cancellation. New Blueprint type families: 'variance', 'stddev'.
Weighted average + boolean rollups (M3) (#136). Aggregate::weightedAvg(value, weight), Aggregate::boolOr(source), Aggregate::boolAnd(source) — three new delta-maintainable kinds. WeightedAvg promotes two Sum companions (Σ(weight·value), Σ(weight)); BoolOr / BoolAnd share a single Sum(bool AS INT) + Count pair. Storage: nullable decimal for weighted average, native boolean (BOOLEAN / TINYINT(1) / INTEGER) for the bool kinds. Portable TRUE/FALSE literals + 1.0 * decimal-coercion path verified across all four backends.
Geometric and harmonic mean (M4) (#130). Aggregate::geometricMean(source) / Aggregate::harmonicMean(source). Geometric rides on __sum_log (Σ LN(x) over positive values) + __count; harmonic rides on __sum_recip (Σ 1/x over non-zero values) + __count. Two new CompanionSourceTransform cases (Ln, Recip) let companion Sums apply a source expression rather than a raw column. Delta SET clauses emit display-column-first (MySQL left-to-right evaluation): EXP((sum_log + Δ) / NULLIF(count + Δ, 0)) for geometric, NULLIF(count + Δ, 0) / NULLIF(sum_recip + Δ, 0) for harmonic. Source-constraint validation: AggregateSourceConstraintViolationException at save time on a non-positive geometricMean value or a zero harmonicMean value; ->allowNonPositive() opts into silent-skip.
Bug fixed en route: applyAggregateOnDelete, collectMoveSubtreeContribution, and applyAggregateOnRestore used numeric() (returns int, truncates via (int) cast) for the SQL Sum branch, silently zeroing the delta for any float companion (LN(x), 1/x, decimal-sum). Switched to numericPreserveType() to match the listener branches already in the same methods.
Median and percentile — fresh-read-only quantiles (M5) (#131). Aggregate::median(source), Aggregate::percentile(source, p), plus Aggregate::percentiles(source, ['alias' => p, …]) and Aggregate::quartiles(source) spread-helpers for withFreshAggregates() only — no stored column, no maintenance lifecycle. PostgreSQL emits PERCENTILE_CONT(p) WITHIN GROUP (ORDER BY col) with optional FILTER; MySQL / MariaDB / SQLite emit a window-function linear-interpolation correlated subquery (ROW_NUMBER() OVER + COUNT(*) OVER) matching PERCENTILE_CONT's semantics (R's type = 7). #[NestedSetAggregate] rejects median: / percentile: with a clear error pointing to withFreshAggregates().
Bitwise rollups — bitOr / bitAnd / bitXor (#127). Feature-flag rollup, capability intersection, and an order-independent subtree fingerprint. bitXor is the only non-Sum-family aggregate with a delta path on both insert and delete (XOR is self-inverse — adding and removing a contribution are the same op). MySQL / MariaDB / PostgreSQL 14+ have all three natively; SQLite gets user-defined aggregates registered on the PDO connection at service-provider boot, so the same SQL works on all four backends.
#[NestedSetAggregate(column: 'features_or', bitOr: 'feature_bits')]
#[NestedSetAggregate(column: 'features_and', bitAnd: 'feature_bits')]
#[NestedSetAggregate(column: 'features_xor', bitXor: 'feature_bits')]
class Module extends Model implements HasNestedSet { use NodeTrait; }
getSubtreeSize() returns slot count (rgt - lft + 1), not node count — README's headline example said 2 for a root+child tree; corrected to 4. Added the missing $root->refresh() before $root->descendants()->get() (stale in-memory bounds returned an empty collection). BulkInsertTreeSaved fires after the closing fixAggregates pass, not before — events.md, the class docblock, and the ordering paragraph all corrected; stored aggregate columns ARE rolled up by the time listeners see the event. NodeMoved.operation enum gained the missing 'sibling-displaced' variant (emitted for the second participant in up() / down() swaps — switch consumers need it or every swap silently misses half its events). AggregateMaintenanceFailed removed from production.md's "safe for ShouldQueue" list (it carries a Throwable). TreeFixResult.nodesUpdated clarified as scoped row count, not repair count. > [!WARNING] callouts on withFreshAggregates() no-arg form (overwrites $original, breaks subsequent save() deltas).Pre-1.0 release with four user-facing additions on top of the v0.13.0 surface: tree exporters for debugging / docs / frontend handoff, four new collection-aggregate kinds, ergonomic move wrappers, and a set of model-carrying events for cache / index / audit listeners. No breaking changes — primitives, attribute API, and NodeMoved semantics from v0.13.0 carry through.
Tree exporters — Mermaid / DOT / ASCII / JSON (#122). Read-only formatters that render a node or whole forest. Subtree exports run one lft-ordered query; *Forest walks every root; *Scope filters a multi-tree model to one tree by scope value. Each format ships with a readonly options object (label closure, withTrashed, format-specific knobs). toJsonTree (not toJson) avoids colliding with Eloquent's Model::toJson(int $options). Cycles raise CorruptTreeException eagerly.
$root->toAsciiTree(); // debugging
$root->toMermaid(); // docs markdown
$root->toJsonTree(new JsonOptions(childrenKey: 'children')); // frontend handoff
Category::toMermaidForest(); // whole table
MenuItem::toDotScope($tenantId); // one tree of a scoped model
Collection aggregates — DistinctCount, StringAgg, JsonAgg, JsonObjectAgg (#124). Four new aggregate kinds on top of SUM/COUNT/AVG/MIN/MAX:
Aggregate::distinctCount(source) — cardinality of a column across the subtree.Aggregate::stringAgg(source, separator, limit, orderBy) + ->distinct() — concatenated, optionally truncated text.Aggregate::jsonAgg(source) — JSON array (scalar, list of columns, or assoc key => column form).Aggregate::jsonObjectAgg(key, value) — JSON {key: value, …} lookup map.All four route through the recompute strategy (no delta fast path). AggregateSqlEmitter centralises per-driver SQL for PG / MySQL / MariaDB / SQLite. The drift comparator (aggregateValuesEqual) is definition-aware: JSON kinds decode + structurally compare so PG jsonb key reordering doesn't produce spurious drift; distinct stringAgg compares as a sorted set of segments. New Blueprint type families: distinct_count, string_agg, json (Laravel's $table->json(...) routes per-driver automatically — jsonb on PG, json on MySQL/MariaDB, text on SQLite).
moveTo / moveBefore / moveAfter ergonomic wrappers (#123). moveTo($parent, $position) picks between appendToNode, prependToNode, and insertBeforeNode based on a position arg ('last' default, 'first', or an int index). Self-excludes $this from the sibling lookup so "position N" means "end up at final index N", not "skip N siblings" — collapses the off-by-one bookkeeping callers used to do. moveBefore / moveAfter are thin aliases over insertBeforeNode / insertAfterNode. Same-position no-op moves still emit a zero-delta NodeMoved (intentional, locked in by test).
Model-carrying events for application-side decoration (#120). 18 new events grouped into four concerns: bulk-insert lifecycle (BulkInsertTreeStarting/Planned/NodeSaved/TreeSaved/Completed), cascade pairs (SubtreeSoftDeleted / SubtreeRestored / SubtreeForceDeleted now carry the full descendant-id set — per-row Eloquent deleted/restored never fire for cascaded descendants), subtree movement (SubtreeMoved lists the descendants the SQL UPDATE renumbered, which NodeMoved alone can't surface), and observability (deferred-maintenance phases, scope-violation diagnostic). Hot paths stay clean for callers that don't subscribe: the descendant-id SELECT only runs when Event::hasListeners(EventClass) returns true. New docs/reference/events.md page documents the full catalogue, payload tables, queue-safety guidance, and ordering guarantees.
docs/aggregates/filtered.md (a single-line > ## Title body was rendering as one giant <h2> and polluting the right-sidebar TOC) (#128).Hardening release — one scope-related bug fix, extensive mutation-testing-driven test hardening, and new CI signals (Infection, OpenSSF Scorecard, Bencher, PHP 8.5).
isSiblingOf (#94) — siblings are now correctly required to share scope. Cross-scope nodes that happened to share a parent_id value were previously reported as siblings.SECURITY.md (#95).Mutation-testing-driven hardening across the aggregate, scope, repair, and assertion-helper paths — escaped mutants killed in:
NodeBounds::contains() boundary (#99)NodeCollection root inference (#102)AggregateMaintenanceFailed event anchorId (#103, #111)fixTree(anchor) narrowing (#104)FixAggregatesJob::displayName (#106)array_keys() unwrap in aggregate error message (#107)fixAggregates(anchor) narrowing (#108)queueFixAggregates JobDispatched anchorId (#109)array_keys assertion tightening (#110)InteractsWithTrees assertion (#112)Count / Min / Max arms of inlineRawFilterExpression (#113)filtersMatch positive arm in AVG companion adoption (#115)FixAggregatesJob chunkSize=0 boundary (#116)A pre-1.0 release with three correctness/perf themes plus docs polish. No breaking changes — all PK-type, contract, and event shapes from v0.11.0 carry through.
rebuildSubtree shifts surroundings when subtree size changes (#83). TreeRepairBuilder::rebuildSubtree($rootId) previously assumed the post-rebuild subtree fit inside the existing lft/rgt band. If descendants had been added via parent_id without a matching makeGap, or removed without closeGap, the rebuild overlapped the next sibling or left a dead gap. Now computes delta = 2 * subtreeCount - (rgt - lft + 1) and makeGap / closeGap around the rebuilt subtree inside the same transaction. Closes F7 in the CORRECTNESS tracker.cursor() (#85). freshListenerAggregate, applyListenerChainRecompute, fixListenerAggregatesPhp, and aggregateErrorsForListeners now stream scalar meta (key, bounds, contribution, stored value) instead of hydrating Eloquent models. Peak memory is O(1) regardless of subtree size — roughly 15–20× memory reduction on listener repairs over large subtrees. SELECT count, contribution-call count, and aggregate values are byte-identical to the pre-refactor path.Real-contention concurrency harness (#82). New ConcurrencyHarness trait built on pcntl_fork exercises lock acquisition against real DB drivers (single-process PHPUnit serialises statements on one connection, so locking is never exercised end-to-end without fork()):
MakeRootConcurrencyTest — 8 workers × 3 saveAsRoot() calls per scope; pins v0.10.0's FOR UPDATE predicate including the scoped variant.AggregateLockingConcurrencyTest — drives the lost-holder MAX recompute under 4 concurrent workers, under both aggregate_locking='auto' and 'always'. Workers retry on SQLSTATE 40001 / 40P01 (deadlock victim).fork()).Public-API fuzzer widening (#86). SoftBranchFuzzerTest, ScopeIsolationFuzzerTest, and TreeStructureFuzzerTest now cover prependToNode, insertBeforeNode, insertAfterNode, makeRoot, up, down, and bulkInsertTree in addition to the previous narrow slice. Default-seed runs are now ~65k assertions (+14% over the 56,682 baseline); random-seed exploration runs cleanly past 1.9M assertions.
Mass-assignment guard scope clarified (#84). docs/aggregates/setup.md now explains that the AggregateConfigurationException mass-assignment check is build-time reflection over $fillable/$guarded. Calls out the runtime bypasses it doesn't catch (overridden isFillable()/isGuarded() and global Model::unguard()) and recommends Model::unguarded(fn () => …) over the global toggle. No code changes.
Live README badges (#87). Four shields.io endpoint badges driven by .github/workflows/badges.yml: tests, assertions, test:src LOC ratio, and CI matrix cell count. The workflow publishes JSON to an orphan badges branch; matrix size is derived from tests.yml at run-time so the count auto-tracks the matrix.
Primary Keys split into its own page (#88). The README's ## Primary keys section moved to docs/getting-started/primary-keys.md (accepted column types + the monotonicity rule for chunked aggregate repair). The migration doc's "Non-integer primary keys" subsection is now a brief pointer to the new page.
A pre-1.0 release with two themes: end-to-end support for non-integer primary keys, and a sweep of test-coverage gaps surfaced by the correctness audit.
HasNestedSet::getParentId() now returns int|string|null. UUIDv7, ULID, time-ordered string keys, and any monotonically-ordered identifier flow through every mutation, repair, aggregate-maintenance, queued-job, and lifecycle-event surface without narrowing to int.parentIdType: argument on the nestedSet() Blueprint macro picks the parent_id column shape. Accepts 'bigint' (default), 'uuid', 'ulid', 'string', or a closure for custom column types (nanoid, fixed-width char, FK constraints).Schema::create('categories', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->string('name');
$table->nestedSet(parentIdType: 'uuid');
});
intKey() rejection in HasTreeMutation is gone; a new keyOf() helper preserves the key's declared type and throws only when the model is unsaved.InteractsWithTrees no longer rejects string PKs — assertIsChildOf, assertHasChildren, and the rest accept any saved key.fixTree($anchor) and fixAggregates($anchor) now reject an unsaved anchor with a clear InvalidArgumentException. Previously a null PK silently collapsed to a whole-table walk — almost never what the caller intended. Read paths (isBroken, countErrors, aggregateErrors) stay permissive so the existing "stub anchor as scope carrier" pattern keeps working.fixAggregatesChunked's blanket 1,000,000-iteration safety bound is replaced with a non-progress (stuck-cursor) detector. The previous bound falsely tripped on legitimate small-chunkSize runs over large tables; the new check fires only when nextAfterId actually fails to advance.fixListenerAggregatesPhp no longer narrows outerKey to int before the chunk-membership check. UUID-keyed models had their listener rows silently skipped during chunked repair.docs/getting-started/migration.md covering supported PK types, the parentIdType: macro argument, and the monotonic-cursor caveat for chunked repair (UUIDv7/ULID/bigint OK; UUIDv4 and default-nanoid use the unchunked fixAggregates($anchor) instead).UuidPrimaryKeyTest fixture pair (UuidTag unscoped + UuidMenu/UuidMenuItem scoped with UUID scope column) — 15 tests covering mutation, repair, aggregate maintenance, chunked listener repair, bulk insert under a UUID anchor, job serialisation roundtrip, scoped isolation, stuck-cursor detection, and unsaved-anchor rejection.makeRoot() no-op, unsaved-anchor rejection on single-node placement, five aggregatesEqual tolerance boundaries, deferred-inside-DB::transaction rollback recovery, chain-shape detector edge cases, bulk-insert stale-anchor footgun pinning, NaN/Inf drift detection, internal AVG companion column leak, and scoped + depth-bounded eager loads.HasNestedSet::getParentId(): ?int → int|string|null. Hand-rolled implementations of the contract need a one-line return-type widening; models using the default NodeTrait get the new behaviour automatically.NodeMoved::$nodeId, FixAggregatesCompleted::$anchorId, FixAggregatesChunkCompleted::$cursorAfter, etc.) widen their PK-typed properties to int|string / int|string|null. Listeners typed against int $nodeId need their parameter type widened.FixAggregatesJob's $anchorId and $cursorAfterId widen to int|string|null. Existing serialised job payloads still deserialise cleanly.fixTree($anchor) and fixAggregates($anchor) reject unsaved anchors. If you were passing a stub model with getKey() === null to scope a repair, fetch a real anchor (or omit the anchor for a whole-table walk).HasTreeMutation::intKey() removed and InteractsWithTrees::keyAsInt() renamed to keyOf() returning int|string. Affects code that subclassed either.All 24 backend cells (PHP 8.3/8.4 × Laravel 11/12/13 × sqlite/mysql/mariadb/pgsql) green on the release commit. PHPStan level 9 with no baseline, Pint + Rector clean, 875 tests / 16,421 assertions.
A pre-1.0 correctness pass driven by a deep review of the public-API behaviour. Focus is on bug fixes and footgun removal rather than new features.
prevSibling() / nextSibling() on roots now scope by the model's declared scope columns, so two scopes with overlapping lft/rgt ranges can't return each other's roots.fixTree() / fixAggregates() / countErrors() / bulkInsertTree() reject anchors that aren't an instance of the model class — previously a wrong-class anchor either silently no-op'd or repaired the wrong subtree.TreeRepairBuilder walkers are iterative; deeply chained trees no longer hit the PHP recursion ceiling on fixTree().makeRoot() calls in the same scope serialise on the max-rgt read (FOR UPDATE), eliminating a duplicate-lft/rgt corruption window.watches list when the SQL references columns; an empty watches list with column references is now rejected at registry build time rather than producing silent drift.FilterPredicate equality evaluation uses strict comparison (!==) so the PHP capture path agrees with SQL's = NULL → unknown → false semantic. Filters touching nullable columns no longer drift between captured deltas and computed-fresh values.PDO::quote, so backslash-bearing values are escaped correctly on MySQL/MariaDB (the previous naive quote rewrote 'foo\bar' to 'foobar' under default sql_mode).TRUE/FALSE SQL literals instead of 1/0, fixing PostgreSQL filters against real BOOLEAN columns.withFreshAggregates() on a withTrashed() / onlyTrashed() outer query now includes trashed descendants in the recompute so the fresh value matches the rowset the outer query yields. freshAggregate() gains an opt-in withTrashed: true parameter for the single-node path.forceDelete() on an interior node now cascades through descendants in the same scope (raw DELETE) and closes the entire subtree gap. Previously left orphans inside a vanished range.appendToNode / prependToNode / insertBeforeNode / insertAfterNode / makeRoot) throws UnplacedNodeException. Catches Model::create([...]) without placement and replicate()->save() without a target — both previously wrote a lft = rgt = 0 corruption.save() (gap, INSERT, aggregate hooks), not just the structural-SQL block. A throw in a created / saved listener after the INSERT now rolls back the gap.withFreshAggregates() documented as read-only: aliased fresh values overlay the stored column, and saving an overlaid model can persist drift back to the store. Recommended pattern uses a distinct alias for side-by-side stored/fresh reads.save() doesn't persist them, refresh() drops them.delete() / forceDelete() paths; orphans only reachable via raw DELETE.FilterValueQuoter and TreeBaseQueryBuilder.Pre-1.0; behaviour changes are intentional. Two paths can break callers if they were relying on prior behaviour:
Model::create([...]) without a placement call now throws UnplacedNodeException. If you used this pattern to seed unplaced rows, add an explicit makeRoot() or appendToNode() call.forceDelete() on an interior node previously left descendants in place; it now cascades. If you were relying on the orphan behaviour (e.g. to re-parent children manually afterwards), capture the descendants before the delete.Final pre-1.0 polish pass across the public API.
id assumption).ancestors / descendants relations.NodeMoved telemetry emitted for both participants of an up() / down() swap.replicate() clears structural columns so clones are unplaced until you appendToNode() or makeRoot().aggregatesEqual uses relative tolerance for large magnitudes.assertSameScope tolerates numeric-type variants (string "1" matches int 1, etc.).AggregateRegistry cache flushed in TestCase::setUp.bulkInsertTree plan walker is iterative (no recursion depth limit).getNodeHeight → getSubtreeSize (deprecated alias kept).Tests for auto_transaction config, TreeQueryBuilder / relation surfaces, interior forceDelete recovery, raw filter value quoting + trashed-descendant moves. Docs for filter SQL-inlining warning and relation-aggregate usage.
Focused bug-hunt pass on the aggregate + soft-delete + replicate surface area.
bulkInsertTree refreshes aggregates above the anchor.forceDelete on a soft-deleted row no longer double-decrements.insertBeforeNode / insertAfterNode on self throws.replicate() resets AVG listener columns, clears deleted_at, and clears structural columns so the clone is unplaced.applyAggregateBeforeMove honours exclusive chain recompute.Gated behind a PHPUnit group (#[Group('fuzzer')]) and configurable via FUZZER_SEEDS / FUZZER_STEPS / FUZZER_RUNS env vars. Covers bulkInsertTree, TreeQueryBuilder, soft-delete cascade, and SoftBranch (SQL aggregates + SoftDeletes).
README split into a 29-page docs site under docs/ (markdown-driven nav, build + live-preview workflow, GitHub Pages deploy on master). Kalnoy references replaced with this package's actual API.
CodeRabbit and dependabot configs added; CLAUDE.md guidance for future Claude Code sessions.
Headline pre-1.0 feature: custom aggregate columns.
Declarative filters on #[NestedSetAggregate]:
filter('column', '=', $value) for simple predicates.filterNotNull('column') for presence checks.filterRaw('column > 0') for arbitrary SQL — auto-correlated, no placeholder ceremony.Delta maintenance for arithmetic aggregates; chain-recompute for raw-filter columns where filter-equivalence can't be proven.
Declare #[NestedSetAggregateListener] for PHP contribution(Model $node) returning mixed:
fixAggregates / aggregateErrors / freshAggregate all handle them.fixAggregates.makeRoot() honours model scope when picking the next root position.fixAggregates on MySQL via renamed-outer derived; batched listener Min/Max recompute reads.bulkInsertTree() — build a subtree from a plan array in one makeGap + N saves + one deferred fixAggregates; full Eloquent semantics (events, casts, observers, etc.).NodeMoved, NodeInserted, etc. emitted on every structural mutation; wire them up to logging / observability.InteractsWithTrees test-time assertion helpers — assertIsRoot, assertIsLeaf, assertIsChildOf, assertTreeIsIntact, assertAggregatesAreIntact.vusys prefix dropped from private/protected identifiers.withFreshAggregates (10–80× on SQLite / MariaDB).fixAggregates (70–250× on long chains).STRAIGHT_JOIN in fixAggregates inner derived (40×).Operational maturity pass on the maintenance surface area.
queueFixAggregates() + FixAggregatesJob — drop a job onto your configured queue instead of blocking the request thread.fixAggregates with onChunk progress callback for monitoring and cancellation.withDeferredAggregateMaintenance(Closure, ?anchor) — wrap a multi-mutation block so aggregate maintenance runs once at the end instead of per-mutation.CASE-WHEN UPDATE in TreeRepairBuilder — 10× faster fixTree() on large trees.withFreshAggregates picks up the same split_materialized treatment fixAggregates already had.docs/CORRUPTION.md).PATHOLOGICAL=1 / run-pathological label).Collapses K correlated sub-queries into one LEFT JOIN LATERAL per inclusivity group on PostgreSQL and MySQL 8 (backends that support the SQL LATERAL keyword). On MariaDB, where LATERAL is not available, an equivalent derived-table shape keeps ordering and row-multiplication semantics consistent.
Same fresh-read query shape on every backend; orders-of-magnitude fewer query plans per call.
Consolidates the in-development v0.7.0–v0.8.0 perf checkpoints.
Replaces correlated subqueries with a JOIN + GROUP BY shape behind a covering index on (scope, lft, rgt, parent_id) and switches the bulk write to a chunked CASE-WHEN UPDATE. The SELECT side is then wrapped in a derived sub-query with BETWEEN-on-lft join predicate so MySQL and SQLite planners pick hash-join + filter + aggregate. MariaDB's split_materialized optimisation is disabled per-statement so its planner stops re-executing the derived once per outer row.
Also establishes the opt-in performance harness: Performance is a separate PHPUnit suite (vendor/bin/phpunit testsuite Performance) and runs on workflow_dispatch / run-perf PR label only.
fixAggregates @ N=10K, intact tree| Backend | Baseline | v0.3.0 | Speedup |
|---|---|---|---|
| SQLite | 17,726 ms | 56 ms | 316× |
| PostgreSQL | 33,824 ms | 1,109 ms | 30× |
| MySQL | 130,606 ms | 4,072 ms | 32× |
| MariaDB | 97,040 ms | 6,925 ms | 14× |
Every backend now sits inside the flat-to-50K target window.
Consolidates the in-development v0.3.0–v0.6.0 perf checkpoints.
Adds declarative aggregate columns to nested-set models: SUM, COUNT, AVG, MIN, MAX. Inserts, source-column updates, deletes, moves, and soft-delete restores all keep stored aggregate values in sync.
$model->tickets_total, single-column read.$model->freshAggregate('tickets_total') / Model::query()->withFreshAggregates() for audit and drift detection.aggregateErrors(), aggregatesAreBroken(), fixAggregates(). fixTree() runs fixAggregates() as a final step.
Initial public-ready state of the package: nested-set algorithm, all four supported backends, scoping, soft-delete cascade, tree repair, strict types throughout, Larastan level 9.
Aggregate-columns feature lands separately in v0.2.0.
How can I help you explore Laravel packages today?