Why Octane Changes Everything About PHP State
Traditional PHP-FPM boots the framework on every request and discards all memory when the request ends. Octane inverts this: a single worker process boots Laravel once and handles thousands of requests in sequence. The performance gain is real, but the contract changes completely — anything you store in a static property, a resolved singleton, or a class-level variable now persists across requests unless you explicitly reset it.
Understanding the three phases of an Octane worker is the foundation for writing safe code.
Phase 1 — Boot (once per worker)
The application container is instantiated, all service providers are registered and booted, and every singleton bound in register() is resolved on first use. This is the only phase where you pay the full framework bootstrap cost.
Phase 2 — Request handling (once per request)
Octane clones the base container into a request-scoped sandbox, dispatches the HTTP kernel, and returns the response. The sandbox clone is shallow — it shares the same singleton instances that were resolved during boot unless you tell Octane to flush them.
Phase 3 — Cleanup (once per request, after response)
Octane fires RequestHandled, runs its own flush callbacks, and discards the shallow clone. Singletons that were resolved during boot are not discarded here.
The Three Categories of State Leakage
1. Singleton Accumulation
A service that appends to an internal array on every request will grow without bound:
class AuditCollector
{
private array $events = [];
public function record(string $event): void
{
$this->events[] = $event; // leaks across requests
}
}
Fix: register a flush callback in your service provider.
public function boot(Application $app): void
{
$app->make(AuditCollector::class); // resolve early so Octane sees it
Octane::flush(function () use ($app) {
$app->make(AuditCollector::class)->reset();
});
}
Alternatively, bind the service as scoped instead of singleton. Scoped bindings are re-resolved on every request inside the sandbox:
$this->app->scoped(AuditCollector::class);
2. Static Property Pollution
Static properties bypass the container entirely, so scoped bindings do not help:
class FeatureFlags
{
private static array $cache = [];
public static function get(string $flag): bool
{
if (!isset(self::$cache[$flag])) {
self::$cache[$flag] = DB::table('flags')->where('name', $flag)->value('enabled');
}
return (bool) self::$cache[$flag];
}
}
This is fine for immutable config, but dangerous if flags can change mid-deployment. Register an Octane flush callback that calls self::$cache = [], or replace the static cache with a request-scoped service.
3. Eloquent Model Event Listeners Stacking
If you call Model::creating(fn () => ...) inside a request (e.g., in a controller or action), the closure is appended to the model's static dispatcher on every request. After 1 000 requests, 1 000 listeners fire for each creating event.
Always register model observers and event listeners in service providers during boot, never inside request handlers.
Memory Management Strategies
Worker Restart Thresholds
Octane's --max-requests flag restarts a worker after N requests, reclaiming all memory:
php artisan octane:start --max-requests=500
This is a safety net, not a substitute for fixing leaks. A well-written Octane app should be stable at 10 000+ requests per worker.
Monitoring Memory Per Request
Add a terminating middleware that logs memory growth:
public function terminate(Request $request, Response $response): void
{
$mb = round(memory_get_usage(true) / 1_048_576, 2);
logger()->channel('octane')->debug('memory', ['mb' => $mb, 'path' => $request->path()]);
}
A flat line across requests means no leakage. A rising sawtooth means something is accumulating.
Identifying Leaks with gc_collect_cycles()
Circular references between objects are not freed by PHP's reference-counting GC. Octane does not call gc_collect_cycles() automatically. Add it to your flush callback for memory-sensitive workers:
Octane::flush(function () {
gc_collect_cycles();
});
Practical Checklist Before Deploying to Octane
- Audit every
singletonbinding: does it accumulate state? Switch toscopedor add a flush callback. - Search for
static $properties in domain code. Decide if they are truly immutable. - Move all model observer and event listener registration to service providers.
- Set
--max-requestsas a backstop, not a crutch. - Add per-request memory logging in staging before promoting to production.
- Test with
php artisan octane:startlocally and run your full test suite against the live worker.
Key Takeaways
- Octane workers are long-lived; PHP's request-scoped memory model no longer applies.
- Use
scopedbindings for services that must be fresh each request. - Static properties require explicit flush callbacks — the container cannot help you.
- Never register model listeners inside request handlers.
- Memory monitoring and
--max-requestsare complementary, not interchangeable.