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.
<?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-requestsso 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-requestsand 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.