Production Notes
1. Routing fresh-aggregate reads to a read replica
withFreshAggregates() runs an aggregation per outer row — on a balanced-fanout tree at N=10K it's the most expensive read the package emits. If you have read replicas, route these reads off the primary:
Category::query()
->withFreshAggregates()
->useReadPdo() // ← stays on Laravel's read connection
->get();
Caveat: Eloquent automatically routes any query inside an open transaction to the write PDO regardless of useReadPdo(), to avoid replication-lag visibility issues. If you wrap the read in a transaction (or call it from inside one), it lands on the primary anyway. For genuine replica routing, the read needs to live outside a transaction boundary.
Pair with the nestedset.aggregate_locking config flag — 'never' is safe on a read-only path; the locking modes only matter for the write path.
2. MariaDB: disabling split_materialized
The fresh-aggregate read path uses a derived-table JOIN on MariaDB so the subquery is materialised once per outer query rather than once per row. MariaDB's optimizer can convert that derived JOIN into a LATERAL DERIVED via split_materialized, which collapses the materialise-once advantage and runs ~3× slower in practice. withMariaDbSplitMaterializedOff() prepends a SET STATEMENT optimizer_switch='split_materialized=off' FOR … to the next compiled SQL — scoped to the one statement, no session-state mutation:
Category::query()
->withFreshAggregates()
->withMariaDbSplitMaterializedOff()
->get();
No-op on MySQL/PostgreSQL/SQLite — the SET STATEMENT prefix is MariaDB-specific syntax. Only reach for it if profiling shows the fresh-aggregate path running unexpectedly slow on MariaDB.
Note
The flag is consulted inside Builder::runSelect() only — i.e. it takes effect on ->get() / ->first() / ->paginate() / ->chunk() (all of which funnel through get() → runSelect()). Calls that bypass runSelect() — ->cursor() (calls Connection::cursor() directly), aggregate methods (->count(), ->sum(), ->exists()), and DML (->update(), ->delete()) — don't carry the prefix. In practice this matches the use case — the fresh-aggregate overhead is on the row-returning paths — but worth knowing if you profile a ->count() (or stream via ->cursor()) and wonder why the optimisation didn't show up. For cursor-style streaming over a derived-shape fresh-read query on MariaDB, wrap the read in a chunk() instead.
3. Telemetry
The package fires typed events on Laravel's event bus around every meaningful operation — bulk insert lifecycle, structural moves, cascade soft-delete / restore / force-delete, tree repair, aggregate maintenance, scope-violation guards. See the dedicated Events page for the full catalogue, payload tables, queue-safety guidance, and worked recipes (search indexing, cache invalidation, audit logs, Datadog / Sentry wirings).
Quick links:
- Telemetry events (scalar payloads, safe for
ShouldQueue):FixTreeCompleted,FixAggregatesCompleted,FixAggregatesChunkCompleted,FixAggregatesJobDispatched,DeferredAggregateMaintenanceCompleted,DeferredMaintenanceStarting,TreeIntegrityChecked,AggregateDriftDetected,NodeAggregatesRecomputed,NodeMoved,ScopeViolationDetected,BulkInsertTreeCompleted. - Model-carrying events (live Eloquent instances, not queue-safe by default):
BulkInsertTreeStarting,BulkInsertTreePlanned,BulkInsertNodeSaved,BulkInsertTreeSaved, everySubtree*event,NodesSwapped,NodePromotedToRoot,SoftDeleteMarkerCaptured. - Exception-carrying (not queue-safe —
Throwablewon't serialise across most drivers):AggregateMaintenanceFailed. Capture$e->stage/$e->modelClasssynchronously and forward those if you want a queued listener.
Set nestedset.events_enabled => false to short-circuit every firing site. Default is true.