The Problem Nobody Talks About: Octane Changes When N+1 Bites You
In a classic PHP-FPM setup, every request boots a fresh process. Stale Eloquent relations, forgotten eager-loads, and singleton state are all silently reset. Move to Laravel Octane (whether on Swoole, RoadRunner, or FrankenPHP worker mode) and that safety net disappears.
The same N+1 query that was invisible under FPM — because each request paid the boot cost anyway — now compounds across thousands of requests in the same worker, and stale relation caches from request A bleed into request B.
Why Octane Amplifies N+1 Pain
Consider a typical resource controller:
// OrderController.php
public function index(): JsonResponse
{
$orders = Order::all(); // no eager load
return response()->json(
$orders->map(fn($o) => [
'id' => $o->id,
'customer' => $o->customer->name, // N+1 here
'items' => $o->items->count(), // N+1 here
])
);
}
Under FPM this fires 1 + N + N queries per request. Under Octane it fires the same count, but the worker handles hundreds of requests per second — the database connection pool saturates fast and latency spikes.
Detecting N+1 in Worker Context
Use DB::listen scoped to a request lifecycle, not a global listener that accumulates across requests:
// AppServiceProvider::boot()
if (app()->environment('local')) {
$this->app->make('events')->listen(
RequestHandled::class,
function () {
// Telescope or a custom collector resets here
}
);
}
Better: install Laravel Telescope and enable the QueryWatcher. With Octane, make sure you flush Telescope's request context on octane:request-terminated:
// OctaneServiceProvider or a dedicated listener
use Laravel\Octane\Events\RequestTerminated;
Event::listen(RequestTerminated::class, function () {
app(\Laravel\Telescope\Telescope::class)->flushEntries();
});
Without this, Telescope accumulates all queries from every request in the worker's lifetime into a single "request" entry — useless for diagnosis.
Fixing the Root Cause: Eager Loading with Constraints
$orders = Order::with([
'customer:id,name', // select only what you need
'items' => fn($q) => $q->select('id','order_id'), // constrained
])->latest()->paginate(50);
For deeply nested relations that vary by context, use lazy eager loading defensively:
$orders->loadMissing('customer', 'items');
loadMissing skips already-loaded relations, which matters in Octane because a singleton service might cache a partially-loaded collection across requests.
Stale Relation Caches: The Octane-Specific Hazard
If you store an Eloquent model in a singleton (common in multi-tenant resolvers), its loaded relations persist across requests:
// Dangerous singleton pattern
app()->singleton(CurrentPlan::class, function () {
return Plan::with('features')->find(config('plan.id'));
});
Request 1 loads Plan with features. Request 2 gets the cached instance — but if features changed in the DB, the stale relation is served silently.
Fix: scope singletons to the request, not the worker:
app()->scoped(CurrentPlan::class, function () {
return Plan::with('features')->find(config('plan.id'));
});
scoped() (added in Laravel 9) re-resolves the binding on each Octane request cycle, giving you singleton-like performance within a request while staying fresh across requests.
Covering Indexes to Absorb the Remaining Queries
After eager loading, profile the queries that remain. For paginated list queries, a covering index eliminates heap fetches:
CREATE INDEX idx_orders_customer_created
ON orders (customer_id, created_at DESC)
INCLUDE (id, status, total);
Laravel's EXPLAIN helper makes this easy to verify:
DB::table('orders')
->where('customer_id', 42)
->orderByDesc('created_at')
->explain()
->dd();
Look for Index Only Scan (PostgreSQL) or Using index (MySQL) in the output.
Takeaways
- Octane doesn't create N+1 bugs — it reveals and amplifies them. Fix eager loading first.
- Use
scoped()instead ofsingleton()for anything that touches the database or tenant state. - Flush Telescope/Debugbar context on
RequestTerminatedor your diagnostics are meaningless. - Covering indexes are the last line of defence once queries are correctly structured.
- Profile under realistic concurrency, not a single sequential request, to see Octane's true query pressure.