The Problem Nobody Talks About Until Production
When you move a Laravel application from PHP-FPM to Octane (whether backed by Swoole, RoadRunner, or FrankenPHP), the container is bootstrapped once and then reused across every request handled by that worker. That single fact invalidates a large class of assumptions most Laravel code silently makes.
This article focuses on the practical patterns you need to write services that behave correctly under persistent workers — and how to audit existing code before you flip the switch.
Why Singletons Become Dangerous
Under PHP-FPM every request gets a fresh process. A singleton bound in a service provider is instantiated once per request, so stale state is impossible — the process dies.
Under Octane a worker process handles thousands of requests. A singleton is instantiated once for the lifetime of the worker. Any mutable state it accumulates is visible to every subsequent request.
// Dangerous under Octane
class CartService
{
private array $items = [];
public function add(int $productId): void
{
$this->items[] = $productId; // leaks across requests!
}
}
Request 1 adds item 42. Request 2 from a completely different user now sees item 42 in $this->items.
The Octane Flush Hook
Octane ships with a RequestHandled event and a dedicated OctaneServiceProvider hook for resetting state between requests.
use Laravel\Octane\Facades\Octane;
class CartServiceProvider extends ServiceProvider
{
public function boot(): void
{
Octane::tick('flush-cart', function () {
$this->app->forgetInstance(CartService::class);
})->everySeconds(0); // runs between every request
}
}
A cleaner approach is to listen to the RequestHandled event directly:
use Laravel\Octane\Events\RequestHandled;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->app['events']->listen(RequestHandled::class, function () {
$this->app->forgetInstance(CartService::class);
});
}
}
Now CartService is re-instantiated fresh on the next request, eliminating the leak.
Designing Octane-Safe Services From the Start
Prefer Immutable Value Objects
If a service only holds configuration (injected at construction) and never accumulates mutable state, it is safe as a singleton.
final class CurrencyFormatter
{
public function __construct(
private readonly string $locale,
private readonly string $currency,
) {}
public function format(int $cents): string
{
return \NumberFormatter::create($this->locale, \NumberFormatter::CURRENCY)
->formatCurrency($cents / 100, $this->currency);
}
}
No mutable fields — safe to keep alive for the worker's entire lifetime.
Inject Request-Scoped Data via Method Arguments
Avoid storing per-request data on the service. Pass it as method arguments instead.
// Bad: stores request context on the singleton
class AuditLogger
{
private ?User $actor = null;
public function setActor(User $user): void { $this->actor = $user; }
public function log(string $event): void { /* uses $this->actor */ }
}
// Good: actor is a method parameter
class AuditLogger
{
public function log(User $actor, string $event): void { /* stateless */ }
}
Use scoped() for Request-Lifetime Bindings
Laravel's scoped() binding (introduced for Octane compatibility) registers a singleton that Octane automatically flushes between requests:
$this->app->scoped(CartService::class, fn () => new CartService());
This is the idiomatic solution — prefer it over manual forgetInstance calls.
Auditing Existing Code
Before enabling Octane on an existing app, grep for these patterns:
$this->app->singleton(...)— check if the resolved class holds mutable instance state.- Static properties on any class (
private static array $cache = []) — these persist across requests and across workers sharing the same process. - Facades that resolve to singletons with state (e.g., a custom
Authdriver that caches the resolved user).
grep -rn 'static \$' app/
grep -rn 'singleton(' app/Providers/
For each hit, ask: does this class accumulate state that must be per-request? If yes, switch to scoped() or add a flush listener.
Takeaways
- Under Octane/FrankenPHP,
singleton()bindings live for the worker's lifetime — mutable state leaks across requests. - Use
scoped()for any service that holds per-request state; Octane flushes scoped bindings automatically. - Immutable services (pure config, no mutable fields) are safe as true singletons and benefit from zero re-instantiation cost.
- Audit static properties carefully — they survive even
forgetInstancecalls. - Listen to
RequestHandledfor custom teardown logic thatscoped()cannot cover.