The Problem Nobody Warns You About
When you move a Laravel application from PHP-FPM to Octane (whether backed by Swoole, RoadRunner, or FrankenPHP), the process no longer dies after each request. The service container is booted once and reused. That is the entire source of the performance gain — and the entire source of subtle bugs.
Understanding which bindings survive across requests, which must be re-resolved per request, and how to enforce that boundary is the difference between a stable production deployment and a system that leaks user data between sessions.
How the Container Lifetime Works
Octane boots the application once in the worker process, then for each incoming request it:
- Creates a sandbox clone of the container.
- Re-binds a small set of core request-aware services (
Request,Auth,Session,Cookie, etc.). - Runs your route/middleware stack against the sandbox.
- Discards the sandbox after the response is sent.
The base container — and every singleton registered in a service provider's register() method — persists for the lifetime of the worker.
// This singleton is booted ONCE and shared across every request.
// Safe only if it holds no per-request state.
$this->app->singleton(ReportRenderer::class, function () {
return new ReportRenderer(config('reports'));
});
If ReportRenderer caches a reference to the current Auth::user() internally, you have a data-leak bug.
Identifying Dangerous Singletons
The rule is simple: a singleton is safe if its constructor and methods are pure with respect to request identity. Danger signs:
- Storing
$this->user = auth()->user()in a property. - Injecting
Requestvia the constructor (not a closure). - Caching database results in an instance property without a TTL.
- Holding an open database transaction reference.
Octane ships with a octane:check command (Laravel 11+) that statically warns about some of these, but it cannot catch everything.
The Correct Pattern: Request-Scoped Bindings
For services that must know about the current request, register them as scoped bindings. Octane flushes scoped bindings between requests automatically.
// ServiceProvider::register()
$this->app->scoped(CurrentTenant::class, function (Application $app) {
return new CurrentTenant(
$app->make(Request::class)->header('X-Tenant-ID')
);
});
scoped() behaves like singleton() within a single request lifecycle but is re-resolved on the next request. This is the correct tool for anything tenant-aware, user-aware, or request-context-aware.
Boot-Once Singletons: Maximising the Warm-Up Benefit
For genuinely stateless, expensive-to-construct services — compiled Twig environments, parsed config trees, HTTP client pools — you want them to survive across requests. Make that intent explicit:
$this->app->singleton(CompiledRuleEngine::class, function () {
// Expensive: parses 400 YAML rule files.
return RuleEngineFactory::fromDisk(storage_path('rules'));
});
Document this in a @octane-persistent docblock annotation (a convention, not a framework feature) so future maintainers know the class must remain stateless.
Detecting Leaks in Tests
With Pest, you can assert that a scoped binding is not accidentally promoted to a singleton:
it('resolves a fresh CurrentTenant per request sandbox', function () {
$app = app();
$first = $app->make(CurrentTenant::class);
// Simulate Octane flushing scoped instances.
$app->forgetScopedInstances();
$second = $app->make(CurrentTenant::class);
expect($first)->not->toBe($second);
});
forgetScopedInstances() is the same method Octane calls internally between requests, making this a faithful unit test.
FrankenPHP-Specific Considerations
FrankenPHP's worker mode runs the Laravel bootstrap inside a loop. Unlike Swoole, it does not use coroutines, so there is no concurrent request sharing within a single worker. However, the same singleton lifetime rules apply. One additional concern: static class properties are never reset by Octane's sandbox mechanism. If a third-party package accumulates state in a static array, you must reset it manually via Octane's RequestHandled event:
use Laravel\Octane\Events\RequestHandled;
Event::listen(RequestHandled::class, function () {
SomePackage::resetStaticCache();
});
Takeaways
- Use
singleton()only for stateless, request-identity-free services. - Use
scoped()for anything that touches auth, tenancy, or the current request. forgetScopedInstances()in Pest tests faithfully simulates Octane's request boundary.- Static class properties bypass Octane's sandbox — reset them via
RequestHandled. - Run
php artisan octane:checkas a CI step, but treat it as a starting point, not a guarantee.