Request Lifecycle: How Web Frameworks Handle a Request · PHP Frameworks: Laravel, Octane and Symfony

Laravel Octane and Long-Running Workers

Why, after switching to Octane, a user briefly saw another customer's name, and which kinds of state leak between requests when the application stays in memory.

Scenario

Example app: to cut the bootstrap cost from the previous lesson, the team runs the Laravel app with Laravel Octane (Swoole, RoadRunner or FrankenPHP). Requests get much faster.

What you see in production

  • A support ticket: "the header showed someone else's company name for a moment".
  • Memory usage of each worker grows slowly until workers are restarted.
  • The bug never reproduces locally under php artisan serve.

The naive code

// AppServiceProvider::register()
$this->app->singleton(CurrentTenant::class, function ($app) {
    return new CurrentTenant($app['request']->user()->tenant);   // captured from the FIRST request
});

class ReportCache {
    public static array $rows = [];       // static state that is never cleared
}

Reproduce it

Under Octane, the application is booted once per worker and then handles many requests. The simulation runs three requests from different users through one worker.

A singleton and a static array leaking across requestsTry it yourself
<?php
class Container {
    private array $singletons = [];
    private array $scoped = [];
    public function singleton(string $id, callable $make) { return $this->singletons[$id] ??= $make(); }
    public function scoped(string $id, callable $make) { return $this->scoped[$id] ??= $make(); }
    public function flushScoped(): void { $this->scoped = []; }   // Octane does this between requests
}

class ReportCache { public static array $rows = []; }

$app = new Container();              // booted once, reused by every request on this worker

foreach (["ada (Acme)", "bob (Globex)", "cy (Initech)"] as $i => $user) {
    $tenant = explode("(", rtrim($user, ")"))[1];
    $singleton = $app->singleton("tenant", fn() => $tenant);      // naive
    $scoped = $app->scoped("tenant", fn() => $tenant);            // fix: scoped binding
    ReportCache::$rows[] = str_repeat("x", 1000);                  // grows forever
    printf("request %d for %-13s singleton tenant=%-7s scoped tenant=%-7s static rows=%d\n",
        $i + 1, $user, $singleton, $scoped, count(ReportCache::$rows));
    $app->flushScoped();
}

Output

request 1 for ada (Acme)    singleton tenant=Acme    scoped tenant=Acme    static rows=1
request 2 for bob (Globex)  singleton tenant=Acme    scoped tenant=Globex  static rows=2
request 3 for cy (Initech)  singleton tenant=Acme    scoped tenant=Initech static rows=3

Root cause

PHP-FPM's shared-nothing model hid these bugs: everything was rebuilt on every request. A long-running worker keeps the application, the container and all static properties in memory between requests, so anything that captures request-specific data (the request, the user, the tenant, the config at that moment) or accumulates data leaks into later requests.

The fix

From the Octane documentation's guidance:

  • Don't inject the request, the container or the config repository into singletons' constructors. Resolve them when needed (request() or a closure) instead.
  • Bind per-request services with $this->app->scoped(): Octane flushes scoped instances between requests.
  • Avoid static properties for request data; if you must cache, bound the cache and reset it (Octane can run listeners on RequestTerminated).
  • Set --max-requests so workers are recycled periodically, limiting the impact of slow memory leaks.
  • Test with Octane locally and in CI, not only with php artisan serve.

Trade-offs

  • Octane removes per-request bootstrap cost (often a large share of response time) but requires code and packages to be written for long-running processes.
  • Some packages keep static state and aren't Octane-safe; check before adopting.

Detect it in production

  • Track per-worker memory over time; a steady climb means accumulating state.
  • Add assertions or logging that compare the authenticated user with tenant-scoped data in responses during rollout.

Key points

  • Octane boots the app once per worker; the container and statics live across requests.
  • Never capture request, user or tenant data in singletons or static properties; use scoped bindings.
  • Recycle workers with --max-requests and test under Octane before production.

Practice questions

What changes in the request lifecycle when a Laravel app runs on Octane?

Answer

The application is bootstrapped and its providers booted once per worker; subsequent requests reuse the same container and static state. Bootstrap cost disappears, but request-specific data captured in singletons or statics leaks between requests.

How do scoped bindings help under Octane?

Answer

$app->scoped() creates one instance per request (or job), and Octane flushes scoped instances between requests, so per-request services don't leak like singletons do.

Why should you avoid injecting the request into a singleton's constructor?

Answer

The singleton is created once and keeps the first request it was given, so later requests see stale data such as another user. Resolve the request lazily or use a scoped binding.

Open in the app to mark lessons complete, take notes and track your progress.