Laravel Octane + FrankenPHP: Eliminating N+1 Queries in Long-Lived Worker Processes
#laravel #octane #eloquent #performance #frankenphp

Laravel Octane + FrankenPHP: Eliminating N+1 Queries in Long-Lived Worker Processes

4 min read Mohamed Said Mohamed Said

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 of singleton() for anything that touches the database or tenant state.
  • Flush Telescope/Debugbar context on RequestTerminated or 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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does `scoped()` have a performance cost compared to `singleton()` in Octane?
Minimal. `scoped()` resolves the binding once per Octane request cycle and caches it for the duration of that request, so within a single request it behaves identically to a singleton. The re-resolution overhead is negligible compared to a database query.
Q02 Will Laravel Debugbar work correctly with Octane to show per-request query counts?
Not out of the box. Debugbar accumulates data in a static collector that persists across Octane requests. You need to listen to the `RequestTerminated` event and call `Debugbar::disable()` or reset its collectors, or switch to Telescope with proper Octane flushing.
Q03 Is `loadMissing` safe to call on a collection that was cached in a scoped binding?
Yes. `loadMissing` checks the in-memory relation cache on each model and only fires queries for relations not yet loaded. In a scoped binding the model is fresh per request, so `loadMissing` behaves predictably without risk of serving stale data.

Continue reading

More Articles

View all