log in
consulting hosting industries the daily tools about contact

Laravel Octane: What Bites You When Real Traffic Hits

Octane is genuinely fast, but the memory and state bugs it surfaces are silent until you're live. Here's what to instrument before you ship.

Octane made one of my apps feel like a different stack. Response times dropped from ~180ms to under 20ms on cached reads. I shipped it, felt clever, and then watched a healthcare portal start returning stale user data to the wrong sessions three days later. Not security-related, thankfully — just a singleton holding onto a previous request's tenant context — but it was enough to make me rebuild my entire pre-deploy checklist.

If you're moving a Laravel app to Octane and you haven't specifically hunted for state bleed, you have bugs you don't know about yet.

What Octane Actually Changes

In traditional PHP-FPM, every request boots the framework from scratch. Laravel registers its service container, resolves providers, builds singletons — all of it — and then throws everything away when the response is sent. It's wasteful, but it's also safe. Shared mutable state doesn't exist between requests because there's no process alive long enough to share it.

Octane changes that. With Swoole or FrankenPHP, a set of workers boot once and stay alive for hundreds or thousands of requests. The framework bootstrap runs once. Your singletons persist. Anything you bind into the container as a singleton — or anything that acts like one because you wrote it that way — is now shared across requests unless you explicitly reset it.

That's the performance win. It's also the footgun.

The Failure Modes in Practice

I've hit three distinct categories of bugs in production Octane deployments.

1. Singleton state bleed

A service class you bound as a singleton carries instance variables from request to request. The canonical example is a tenant resolver — you set $this->currentTenant in some middleware, and the next request gets it if your reset logic doesn't fire cleanly.

2. Static property accumulation

Static properties on classes never get reset between requests. I had a rate-limit tracking class that appended to a static array for local in-memory short-circuit logic. Under FPM it was harmless — the array was empty on every request. Under Octane that array grew without bound until the worker ran out of memory and died.

3. Closure and listener memory leaks

Event listeners registered inside service providers can accumulate if you're registering them per-request somewhere (middleware, a booted model callback, etc.) instead of once at boot. Each request adds another listener. Memory climbs slowly. Workers restart on a schedule instead of a clear trigger, which makes this one hard to diagnose.

What to Instrument Before You Ship

Don't wait for production to find this. Here's the actual instrumentation I add to every Octane project before go-live.

Track Per-Request Memory Delta

Add a middleware that logs memory before and after each request. You want to see the delta, not the absolute value, because the baseline climbs as the worker ages.

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class OctaneMemoryProfiler
{
    public function handle(Request $request, Closure $next)
    {
        $before = memory_get_usage(true);

        $response = $next($request);

        $after  = memory_get_usage(true);
        $delta  = $after - $before;
        $peak   = memory_get_peak_usage(true);

        if ($delta > 512 * 1024) { // flag anything over 512KB growth
            Log::warning('octane.memory_growth', [
                'route'  => $request->path(),
                'method' => $request->method(),
                'delta'  => $delta,
                'peak'   => $peak,
                'worker' => getmypid(),
            ]);
        }

        return $response;
    }
}

Register this in your web and api middleware groups during staging. Filter by worker PID so you can watch a single worker's memory over time. If a route consistently shows positive deltas that don't recover, you have a leak in that code path.

Use Octane's Built-In Request Lifecycle Hooks

Octane exposes tick, requestReceived, requestHandled, and workerErrorOccurred events via the Octane facade. Use them.

// In a service provider's boot() method

use Laravel\Octane\Facades\Octane;

Octane::tick('memory-check', function () {
    $mb = round(memory_get_usage(true) / 1048576, 2);
    if ($mb > 128) {
        Log::warning('octane.worker_memory_high', [
            'mb'     => $mb,
            'worker' => getmypid(),
        ]);
    }
})->seconds(10);

That tick runs every 10 seconds in each worker. If a worker climbs past your threshold between requests, something is accumulating outside the request cycle — likely a queued job callback, a scheduled task, or a static property.

Explicitly Audit Your Singletons

Run this command and read every line:

php artisan octane:check

Octane's built-in checker will flag some obvious cases, but it doesn't know about your application's custom singletons. I keep a manual list. Any class bound as a singleton that holds state needs to either:

  1. Be listed in octane.php under flush so Octane re-resolves it each request, or
  2. Implement __clone cleanly and be listed under warm with explicit reset logic.

The config looks like this:

// config/octane.php

'flush' => [
    \App\Services\TenantContext::class,
    \App\Services\CurrentUserResolver::class,
],

If you're not sure whether something holds state, assume it does and add it to flush. The performance cost of re-resolving a few services per request is negligible compared to the debugging cost of a state bleed in production.

Write a Concurrency Smoke Test

This is the one most people skip. Before launch, hit your app with concurrent requests that should return different data per user.

# Install wrk or use Apache Bench
# Hit an authenticated endpoint as two different users simultaneously
ab -n 500 -c 50 -H "Authorization: Bearer $TOKEN_USER_A" https://staging.yourapp.com/api/profile

Then do the same test from a script that checks response payloads and flags any response that contains data belonging to the wrong user. I use a small Python script that sends requests for two users interleaved and asserts response['user_id'] == expected_id. If it fails even once in 500 requests, you have a real bleed.

I build this into the CI pipeline for every Octane project now. It runs against the staging environment on every deploy. Took me about two hours to write after the healthcare incident. Should have written it before.

The Gotchas That Are Easy to Miss

Carbon macros registered in providers — if you call Carbon::macro() inside a method that runs per-request rather than once at boot, you'll register the macro hundreds of times. It won't error, it'll just grow. Check that all macro registrations happen in a boot() method, not in middleware or controllers.

Request-scoped services that cache the request object — if a service stores $this->request = $request in its constructor and that service is a singleton, it will hold a reference to a previous request's data indefinitely. Always inject Request via the method signature or use request() helper at call time, not at construction time.

Database connection state — Octane handles connection reset between requests, but if you're doing anything with DB::connection()->setPDO() or custom connection resolution, verify that your connections are properly returned to the pool. I've seen workers accumulate open connections to Postgres under load until they hit the server's max_connections limit.

Queue job closures — if you're dispatching closures to the queue (don't, but people do), and those closures capture large objects from the request context, you're serializing that data and potentially leaking memory in the worker before serialization completes.

When I'd Reach for Octane (and When I Wouldn't)

Octane is a good fit for APIs with high request volume and limited side effects — read-heavy endpoints, stateless microservices, internal tooling where you control all the consumers. I run it in front of a biotech LIMS dashboard that serves a lot of read traffic and the performance difference is dramatic and real.

I wouldn't reach for it for apps with a lot of legacy code where you can't audit every service binding. I wouldn't use it for apps that are heavily dependent on third-party packages that were written assuming FPM semantics — some popular packages do surprising things with static properties. And I wouldn't use it if you don't have the instrumentation in place to detect leaks, because you'll ship something that works fine until it doesn't, and you won't know why.

The flush config and the memory middleware I described above are the minimum viable safety net. The concurrency smoke test is what actually gives you confidence.

Bottom Line

Octane is not a drop-in swap. It's a different execution model that makes your implicit assumptions about request isolation suddenly explicit and dangerous. Add the instrumentation first, audit your singletons, write the concurrency test — then enjoy the speed. Going the other direction is a bad time.

Need help shipping something like this? Get in touch.