When to use this
- You're moving a Laravel app from php-fpm to Octane (Swoole or RoadRunner), where the framework boots once and the container persists across many requests.
- You're seeing data bleed between requests, stale configuration, or bugs that disappear after a worker restart.
Prerequisites
- Octane installed and running locally with more than one worker.
- A concurrency/load test harness that can interleave requests from different tenants or users.
- An inventory of your singleton bindings and static state to audit.
Procedure
- 1
Audit for request-scoped state held beyond the request
Under php-fpm every request is a fresh process, so a singleton holding the current user or tenant is harmless — it dies at the end of the request. Under Octane that singleton lives for thousands of requests. Find every place request-scoped data is stashed somewhere long-lived.
Surface the usual offenders # Singletons, static state, and anything caching a request/user/tenant. grep -rn "singleton(" app/ config/ grep -rn "static \$" app/ grep -rn "app()->instance(" app/ - 2
Bind request-scoped services as scoped, and flush between requests
Services that carry per-request state must be registered with `scoped()` (not `singleton()`) so Octane rebuilds them each request, and their state must be flushed on request boundaries. Octane's config lists services to flush between requests — add yours.
Scoped binding + Octane flush // A service that carries the current tenant is scoped, not singleton. $this->app->scoped(TenantContext::class); // config/octane.php — reset these on every request boundary. 'flush' => [ TenantContext::class, // ...any service that must not survive a request ], - 3
Never hold the request, auth, or tenant in a long-lived binding
A singleton that captures `request()`, `Auth::user()`, or the tenant at construction freezes the first request's values for the life of the worker. Resolve these live, per call, from the current context — never cache them on a long-lived object.
Resolve live; don't freeze at construction // WRONG under Octane — captures request #1's user forever. public function __construct() { $this->user = Auth::user(); } // RIGHT — resolve at call time, every time. public function currentUser(): ?User { return Auth::user(); } - 4
Reset third-party and static state that assumes a fresh process
Libraries that keep static caches, and your own static properties, assume they start empty each request. Under Octane they don't. Reset them on `RequestReceived`, or move them off static storage entirely.
- 5
Treat config as immutable after boot
`config()->set(...)` at runtime mutates shared state that the next request inherits. Configuration is read once at boot and never written at request time. If a value must vary per request, it belongs in the request-scoped context, not in config.
Verify it worked
- Interleaved-concurrency test: fire requests for two different tenants at once, repeatedly, and assert neither ever sees the other's data.
- Restart-independence: behaviour on request 10,000 is identical to request 1 — no drift as the worker ages.
- Worker RSS is stable over a long run; no unbounded growth that signals accumulating per-request state.
If it goes wrong — rollback
- Set Octane to a single worker as an immediate stopgap — it serialises requests and hides cross-request bleed while you fix the root cause.
- If leaks are widespread, revert the route/app to php-fpm and re-introduce Octane once the audit is complete.