5 min read

Why Your Laravel Octane App Is Leaking Memory (And Serving Stale Data)

Laravel Octane promises blazing speed, but its long-lived process model introduces memory leaks and stale state bugs that can silently corrupt your SaaS app in production.
Why Your Laravel Octane App Is Leaking Memory (And Serving Stale Data)

You deployed Laravel Octane to squeeze more performance out of your SaaS app. The benchmarks looked great. Requests were flying. Then, a few days later, things got strange.

Workers start eating RAM and never let it go. A user sees data that belongs to a different session. A config value is wrong — but only sometimes. You restart the workers and everything goes back to normal, until it doesn't.

These aren't random bugs. They're the predictable result of deploying Octane without understanding what it changes about how Laravel works at a fundamental level.

What Octane Actually Does (And Why That Matters)

In a standard PHP setup, every request boots a fresh copy of the application. Laravel's service container is built from scratch, singletons are re-instantiated, and everything is thrown away when the request ends. It's wasteful, but it's safe — state can never bleed between requests because the process itself dies.

Octane breaks that assumption entirely.

With Octane running on Swoole or RoadRunner, your application boots once and stays in memory. Requests are handled by long-lived workers that share that single application instance. This is what makes it fast. It's also what makes it dangerous if your code wasn't written with this in mind.

That "throw it away" safety net is gone.

The Three Ways This Bites You in Production

1. Singletons That Hold Stale Data

Any class registered as a singleton in the service container gets instantiated once and reused across every request. If that singleton caches something — a user object, a request-specific value, a config flag — the second request picks up exactly where the first one left off.

Here's a pattern that looks harmless but isn't:

// AppServiceProvider.php
$this->app->singleton(InvoiceService::class, function ($app) {
    return new InvoiceService($app->make(Request::class));
});

The Request object is injected once, at boot. On request #2, InvoiceService still has request #1's request object — including any headers, user context, or input data that was attached to it.

If your InvoiceService reads $this->request->user() to scope invoice lookups, you've just served one user's invoice data to another.

2. Static Properties That Grow Forever

Static properties in PHP persist for the lifetime of the process. In a traditional setup, that's one request. In Octane, that's potentially thousands.

class EventLogger
{
    protected static array $log = [];

    public static function record(string $event): void
    {
        static::$log[] = $event;
    }
}

Every request appends to $log. After 10,000 requests, that array holds 10,000 entries. After 100,000, your worker is out of memory. No single request caused it — the leak is distributed across all of them.

This includes any package you're using that wasn't designed for long-lived processes. Laravel Debugbar, certain event tracking libraries, and even some first-party Laravel components have been flagged for this exact issue.

3. Service Container Callbacks Accumulating

The ViewServiceProvider issue — reported directly in the Laravel Octane GitHub issues — is a good example of framework-level accumulation. A terminating callback gets registered to the app container every time a Blade view is compiled. In a long-lived process, that array of callbacks grows indefinitely, slowing down each request and eventually causing memory pressure.

You don't have to write buggy code for this to happen. You just have to run Octane long enough.

How to Fix It

Inject Closures Instead of Objects

The root issue with stale singletons is injecting resolved objects into constructors. The fix is to inject a resolver instead — a closure that always fetches the current value at call time.

$this->app->singleton(InvoiceService::class, function ($app) {
    return new InvoiceService(fn() => $app->make(Request::class));
});

Inside InvoiceService, replace $this->request->user() with ($this->requestResolver)()->user(). It adds one function call, but it always returns the current request — not the one from boot time.

Alternatively, use the request() helper directly inside your methods rather than storing the request object as a property.

Register Octane's Flush Callbacks

Octane provides a mechanism to reset state between requests. Use it:

use Laravel\Octane\Facades\Octane;

Octane::tick('flush-logger', function () {
    EventLogger::flush();
})->everySecond();

Or register a request flush callback in your service provider:

Octane::flush(function () {
    app()->forgetInstance(InvoiceService::class);
});

This won't fix poorly designed singletons, but it gives you a clean reset point so state doesn't bleed across requests.

Use Scoped Bindings Instead of Singletons

Laravel's scoped() binding is specifically designed for Octane. It behaves like a singleton within a single request lifecycle, but gets cleared automatically between requests.

// Instead of:
$this->app->singleton(InvoiceService::class, fn($app) => new InvoiceService($app));

// Use:
$this->app->scoped(InvoiceService::class, fn($app) => new InvoiceService($app));

For any service that should have per-request state, scoped() is almost always the right binding type when running under Octane.

Set a --max-requests Safety Net

No matter how carefully you audit your code, production surprises happen. Set a maximum request limit so workers gracefully restart before memory pressure becomes an incident:

php artisan octane:start --max-requests=500

This doesn't fix the underlying problem, but it gives you a ceiling. Workers restart cleanly, Supervisor brings them back up, and your app keeps running while you fix the actual leak.

Audit with Memory Logging

Add memory logging to your workers so you can see growth over time:

Octane::tick('memory-check', function () {
    $mb = memory_get_usage(true) / 1024 / 1024;
    if ($mb > 128) {
        Log::warning("Octane worker memory high: {$mb}MB");
    }
})->everySecond();

If you see steady growth over time — not spikes during heavy requests — you have a leak. This narrows down the window considerably.

The Bigger Pattern

Octane is worth using. The performance gains are real, and for SaaS applications handling thousands of requests per minute, the difference is significant.

But Octane doesn't just speed up your existing app — it exposes the assumptions your app was built on. Most Laravel codebases were written for stateless, single-request processes. That's fine. It's just worth understanding what changes when the process is no longer stateless.

The bugs described here aren't edge cases. They show up consistently when teams add Octane to a production app without auditing their service bindings, static state, and third-party packages first. The GitHub issue tracker for laravel/octane has dozens of threads from developers who hit exactly these problems.

If you've turned on Octane and everything is fine — good. Check back in two weeks of production load and see where your worker memory sits.

If you're already seeing unexplained behavior, random data, or creeping memory usage: your app is almost certainly sharing state it shouldn't be. Start with your singleton registrations and any class that stores data as a static property.


These are the kinds of architectural changes that look small on paper but matter enormously at scale. If your team is running into Octane issues — or you're evaluating whether Octane makes sense for your app — feel free to reach out. It's the kind of problem that's much faster to diagnose with someone who's seen it before.