How does Laravel Octane handle state persistence across requests, and how do you prevent catastrophic memory leaks and data bleeding in singletons?
In a high-throughput production environment running Laravel Octane with Swoole/FrankenPHP serving 10,000 req/sec, users reported seeing other users' authenticated profiles, and worker memory consumption steadily increased until pods crashed with OOM.
Root Cause Analysis: Traditional PHP-FPM vs. Long-Running Octane
Under traditional PHP-FPM, the entire application lifecycle (memory, static variables, singletons) is instantiated on request arrival and wiped completely when the request terminates (shared-nothing architecture).
Under Laravel Octane (Swoole, RoadRunner, FrankenPHP), the PHP application boots once into memory. Worker processes persist across thousands of requests. Any state attached to a singleton, static property, or service provider persists across subsequent HTTP requests from completely different clients.
Critical Failure Vectors
- Data Bleeding: Storing request-scoped data (like
Auth::user()or tenant IDs) inside a singleton service constructor or static class property. Subsequent requests will read the prior user's cached identity. - Memory Leak: Appending items into static array caches (
private static array $cache = []) or listening to events without unregistering listeners.
Architectural Solution
-
Use
bindScopedInstead ofsingleton: Laravel Octane resets scoped bindings between requests:// BAD in Octane (persists across requests) $this->app->singleton(TenantManager::class, fn () => new TenantManager($request->header('X-Tenant-ID'))); // GOOD in Octane (purged after each request finishes) $this->app->scoped(TenantManager::class, fn () => new TenantManager(request()->header('X-Tenant-ID'))); -
Octane Warmers & Listeners: Flush state explicitly in
config/octane.phplisteners:OperationTerminated::class => [ FlushTemporaryCaches::class, ResetTenantContext::class, ], -
Pass Callbacks or Resolvers: Instead of caching an instantiated
$userin a service, pass aClosureor resolve dynamically viaAuth::user().