Why Octane Changes Everything About Application State
Traditional PHP-FPM boots the entire Laravel application on every request and discards it afterwards. Octane inverts that model: a worker boots once, then handles thousands of requests inside the same process. The performance gains are real, but the contract your code must honour changes fundamentally.
Understanding the worker lifecycle is not optional — it is the difference between a fast application and one that leaks user data across requests.
The Worker Lifecycle in Detail
When Octane starts (Swoole or RoadRunner), each worker:
- Boots the Laravel application (
Application::boot()). - Resolves and caches all service-provider bindings.
- Enters a request loop, calling
handle()for each incoming HTTP request. - Resets a curated set of framework state between requests via Octane's request lifecycle hooks.
Octane ships with a list of "resettable" services (session, auth, database connections, etc.). Everything outside that list persists across requests unless you explicitly reset it.
Common Leakage Patterns
1. Singletons That Accumulate State
// AppServiceProvider
$this->app->singleton(CartService::class, function () {
return new CartService(); // holds items in a property
});
The CartService instance is created once per worker. If addItem() mutates an internal array, request B sees request A's cart.
Fix — use scoped() instead of singleton():
$this->app->scoped(CartService::class, CartService::class);
scoped() bindings are flushed by Octane between requests automatically.
2. Static Properties
class FeatureFlags
{
private static array $resolved = [];
public static function get(string $flag): bool
{
return self::$resolved[$flag] ??= self::resolve($flag);
}
}
Static properties survive the entire worker lifetime. A flag resolved for user A is returned to user B.
Fix — flush in an Octane listener:
// OctaneServiceProvider or AppServiceProvider
use Laravel\Octane\Facades\Octane;
Octane::tick('flush-feature-flags', function () {
FeatureFlags::flush();
})->everyRequests(1);
Or better, avoid static caches entirely and use the request-scoped IoC container.
3. Resolved Auth / Tenant Context
Multi-tenant apps often resolve the current tenant early and store it somewhere global. Under Octane that context sticks.
// Dangerous under Octane
app()->instance('current.tenant', $tenant);
Fix — use OctaneServiceProvider flush hooks:
use Laravel\Octane\Contracts\ServesStaticFiles;
use Laravel\Octane\Events\RequestReceived;
use Laravel\Octane\Events\RequestTerminated;
Event::listen(RequestReceived::class, function ($event) {
$event->sandbox->forgetInstance('current.tenant');
});
The $event->sandbox is the per-request application clone Octane creates. Flushing on RequestReceived ensures a clean slate.
Memory Management
Workers do not restart between requests, so memory grows. Two practical controls:
Max Requests Per Worker
# octane config
'max_requests' => 500,
Octane gracefully restarts a worker after it has served this many requests. This is your safety net against slow leaks.
Watching for Leaks with memory_get_usage()
Octane::tick('memory-check', function () {
if (memory_get_usage(true) > 128 * 1024 * 1024) {
logger()->warning('Worker memory high', [
'bytes' => memory_get_usage(true),
]);
}
})->everyRequests(50);
Log and alert; do not silently let workers balloon to gigabytes.
Practical Checklist Before Deploying to Octane
- Audit every
singleton()binding — replace withscoped()where state is request-specific. - Search the codebase for
static $properties that cache data. - Ensure third-party packages are Octane-compatible (check their issues trackers).
- Set a sane
max_requests(200–1000 depending on memory profile). - Add
RequestReceivedlisteners to flush any global context (tenant, locale overrides). - Run
php artisan octane:installand review the generatedOctaneServiceProvider.
Takeaways
- Octane workers are long-lived; PHP-FPM assumptions about request isolation no longer hold.
- Prefer
scoped()oversingleton()for any service that touches request-specific data. - Static properties are the hardest leaks to spot — grep for them before going live.
- Use
RequestReceivedandRequestTerminatedevents to flush custom global state. max_requestsis not a workaround; it is a required production safety valve.