Octane Worker Lifecycle, State Leakage, and Memory Management in Production
#laravel #octane #performance #swoole #memory

Octane Worker Lifecycle, State Leakage, and Memory Management in Production

3 min read Mohamed Said Mohamed Said

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:

  1. Boots the Laravel application (Application::boot()).
  2. Resolves and caches all service-provider bindings.
  3. Enters a request loop, calling handle() for each incoming HTTP request.
  4. 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 with scoped() 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 RequestReceived listeners to flush any global context (tenant, locale overrides).
  • Run php artisan octane:install and review the generated OctaneServiceProvider.

Takeaways

  • Octane workers are long-lived; PHP-FPM assumptions about request isolation no longer hold.
  • Prefer scoped() over singleton() for any service that touches request-specific data.
  • Static properties are the hardest leaks to spot — grep for them before going live.
  • Use RequestReceived and RequestTerminated events to flush custom global state.
  • max_requests is not a workaround; it is a required production safety valve.

Found this useful?

Frequently Asked Questions

3 questions
Q01 What is the difference between `singleton()` and `scoped()` in an Octane context?
`singleton()` resolves once per worker process and persists across all requests that worker handles. `scoped()` resolves once per request lifecycle; Octane flushes scoped bindings between requests, giving you isolation without the overhead of a full re-boot.
Q02 Does Octane automatically protect against all state leakage?
No. Octane resets a curated list of framework-owned services (auth, session, database connections). Any application-level singletons, static properties, or globally bound instances you introduce are your responsibility to flush via Octane's request lifecycle events.
Q03 How do I test for state leakage before deploying to production?
Run your test suite with `OCTANE_TESTING=true` and fire multiple sequential requests in a single process using Octane's built-in test helpers. Also inspect memory growth with `memory_get_usage()` across a batch of requests in a staging environment under realistic load.

Continue reading

More Articles

View all