Your Singleton Isn't a Singleton in Laravel Octane
Laravel Octane's persistent process model breaks a core assumption you've been making since day one. Here's what actually happens to your singletons.
I spent an afternoon last year chasing a bug where a healthcare client's per-request tenant context was leaking between requests under load. The app had been running fine on traditional FPM for two years. We moved it to Octane for the performance gains and within a week we had users occasionally seeing another tenant's data flash through. Not a security incident — the window was milliseconds and nothing persisted — but it was enough to make my stomach drop. The fix was simple once I understood what Octane actually does to your service container. Getting to that understanding took longer than it should have.
What Octane Actually Does to Your App
On a normal PHP-FPM stack, every request boots a fresh Laravel application. Your register() and boot() methods run, singletons get bound, everything is clean. When the request ends, the process dies (or gets recycled). You get statefulness for free because the slate wipes itself.
Octane — whether you're running Swoole or RoadRunner — keeps your application process alive between requests. Boot once, serve many. That's where the 4-10x throughput gains come from. It's also where your assumptions fall apart.
The application container that Laravel boots at startup persists. Your service providers run register() and boot() exactly once, at process start. When the next request comes in, Octane resets some things — it flushes certain bindings, resets the auth guard, clears the request instance — but it does this via a list of known services, not a full teardown. Anything you've bound as a singleton that isn't on that list? It lives forever in that worker process.
Forever, in this context, means until the worker restarts.
The Problem With Singletons in a Long-Lived Process
Here's the pattern I see constantly in Laravel apps, including ones I've written:
// AppServiceProvider.php
public function register(): void
{
$this->app->singleton(TenantContext::class, function () {
return new TenantContext();
});
}
And then somewhere in a middleware:
// SetTenantMiddleware.php
public function handle(Request $request, Closure $next): Response
{
$tenant = Tenant::where('domain', $request->getHost())->firstOrFail();
app(TenantContext::class)->setTenant($tenant);
return $next($request);
}
On FPM this is fine. The TenantContext singleton gets created per-request because the container itself is per-request. On Octane, request one sets the tenant to acme-corp. Request two — served by the same worker — calls setTenant() and overwrites it. That's the happy path. The race condition happens when two concurrent requests hit the same worker between the setTenant() call and where the context is actually read. With async Swoole coroutines, that window is real.
What Octane Actually Resets
Octane's RequestHandledListener flushes a configurable list of singletons between requests. You can see this in your config/octane.php:
'flush' => [
// Octane resets these for you
],
And Octane itself resets things like auth, cookie, session, url, the current request, and a handful of others. But it has no idea about TenantContext. It's your code. You have to tell it.
You have two options.
Option 1: Add your singleton to Octane's flush list.
// config/octane.php
'flush' => [
TenantContext::class,
],
Octane will call $this->app->forgetInstance(TenantContext::class) between requests, which forces the container to re-instantiate it fresh on next resolution. Simple, effective, easy to forget when you add a new stateful service six months from now.
Option 2: Don't use a singleton. Use scoped().
This is the better answer for anything that's conceptually per-request:
// AppServiceProvider.php
public function register(): void
{
$this->app->scoped(TenantContext::class, function () {
return new TenantContext();
});
}
scoped() binds like a singleton within a single request lifecycle, then gets automatically flushed by Octane at request end. It was added specifically for this problem. Use it for anything that should be shared within a request but not across requests.
Boot Order Makes This Worse
Here's the second layer of the problem, and the one that's harder to debug: service provider boot order.
Laravel boots providers in the order they're declared in config/app.php. register() runs for all providers first, then boot() runs for all providers. This matters because in boot(), you can resolve things from the container — and if you're resolving a singleton that gets mutated during a request, you might be holding a stale reference.
I hit this with a configuration service at a previous client. A third-party package registered a singleton in its service provider. Our service provider, booted after it, resolved that singleton in boot() and cached a value off it:
// Our ServiceProvider boot()
public function boot(): void
{
// Grabbing a value from a singleton at boot time
$apiKey = app(SomePackageConfig::class)->get('api_key');
$this->app->singleton(OurApiClient::class, function () use ($apiKey) {
return new OurApiClient($apiKey);
});
}
On FPM this is harmless — the value is whatever it is at boot, and the whole process dies after the request anyway. On Octane, boot() runs once at process startup, before any request has been handled. If SomePackageConfig reads from a database or from a per-request source, you've just baked in a stale value that will never update for the lifetime of that worker.
The fix is to defer resolution:
public function boot(): void
{
$this->app->singleton(OurApiClient::class, function () {
// Resolve at usage time, not at boot time
$apiKey = app(SomePackageConfig::class)->get('api_key');
return new OurApiClient($apiKey);
});
}
Now the closure doesn't run until the singleton is first resolved during a request. With scoped(), it runs fresh each request. Either way, you're not baking in state at process startup.
The Static Property Footgun
While I'm here: static properties on classes are completely invisible to Octane's flush mechanism. The container has no idea they exist.
class SomeService
{
private static ?DatabaseConnection $connection = null;
public static function getConnection(): DatabaseConnection
{
if (static::$connection === null) {
static::$connection = new DatabaseConnection();
}
return static::$connection;
}
}
That $connection will never be null after the first request on a worker. If the connection goes stale, if credentials rotate, if anything changes — that worker is going to keep serving the cached connection until it crashes or restarts. Octane can't help you here. Audit your codebase for static state before you flip the Octane switch.
When I'd Reach for Octane (and When I Wouldn't)
I'd reach for Octane when I have a high-throughput API — something that's genuinely CPU or I/O bound and would benefit from not re-bootstrapping the framework on every hit. A biotech client of mine runs a data ingestion endpoint that handles hundreds of requests per minute. Octane made a real difference there, and because the endpoint is stateless (no per-request context, just validate-transform-store), we had zero issues.
I'm more cautious with multi-tenant apps, anything that reads configuration from the database per-request, or any app that leans heavily on third-party packages whose service providers I haven't audited. That last one is the one that gets you. You can control your own code. You can't always control what a package author assumed about the application lifecycle.
The octane:start --watch flag during local development helps surface these issues, but honestly the best thing you can do before going to production is write a test that boots the kernel once and fires two fake requests through it with different state, then asserts neither request bleeds into the other.
it('does not leak tenant context between requests', function () {
// First request
$this->actingAs(userForTenant('acme'))
->get('/api/some-endpoint')
->assertJsonPath('tenant', 'acme');
// Second request, same process, different tenant
$this->actingAs(userForTenant('globex'))
->get('/api/some-endpoint')
->assertJsonPath('tenant', 'globex');
});
Pest and PHPUnit both reuse the application instance across tests in a suite. Your test runner is already a rough approximation of an Octane worker. Use it.
The Bottom Line
Octane is a genuine performance win, but it's not a drop-in replacement for FPM on a non-trivial app. The mental model shift from "container-per-request" to "container-per-process" is small in theory and large in practice. Audit your singletons, switch per-request services to scoped(), and for the love of everything avoid baking resolved values into boot(). The performance gains are real. So are the bugs if you skip the audit.
Need help shipping something like this? Get in touch.