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.