Skip to content

Laravel Octane in Production: What FrankenPHP Changed for Me

Laravel Octane in Production: What FrankenPHP Changed for Me

I spent eight months running a Laravel app on plain PHP-FPM and convincing myself the response times were fine. Then I switched a single route to Octane with FrankenPHP, watched the p99 drop by more than half, and felt a little dumb for waiting that long.

Octane isn’t new. It shipped in 2021. What changed for me was FrankenPHP. The previous Octane drivers always felt like adopting a second server I’d have to babysit. Swoole had its own extension to compile. RoadRunner needed a Go binary alongside PHP. Both wanted a deployment flow that sat awkwardly next to the rest of my stack. FrankenPHP is one binary, ships with Caddy, and runs the app in worker mode. That was what tipped it.

This is what I actually learned migrating two production apps over the last few months. What broke, what I shouldn’t have migrated, and the perf numbers that justified the work. If you’ve been on the fence, this is the honest version.

What Octane actually does, for the impatient

Laravel Octane boots your application once and reuses the booted process for every request. With PHP-FPM, every request runs index.php, loads the framework, boots every service provider, then tears it all down at the end. Octane skips that. The framework boots once, then sits in memory waiting for requests.

If 60% of your request time was framework boot, and on a non-trivial Laravel app it often is, you just deleted 60% of your request time.

The catch is the part everyone glosses over. Your code now runs in a long-lived process. Variables you set in one request can leak into the next. Singletons aren’t rebuilt. Global state is shared. This is where the bugs come from.

Why FrankenPHP changed the math

Three things made FrankenPHP land differently for me than Swoole or RoadRunner did.

First, no extra binary to deploy. It runs my PHP code directly via an embedded SAPI. I ship one Docker image.

Second, HTTP/2 and HTTP/3 out of the box via Caddy. I dropped a separate Nginx config the same week.

Third, worker mode is one config change, not a different mental model.

Here’s what migrating an octane.php over to FrankenPHP actually looked like:

// config/octane.php
return [
    'server' => env('OCTANE_SERVER', 'frankenphp'),
    'workers' => env('OCTANE_WORKERS', 4),
    'task_workers' => env('OCTANE_TASK_WORKERS', 6),
    'max_requests' => 500,
    // ...
];

And the Dockerfile collapsed from a multi-stage build with PHP-FPM and Nginx and supervisord down to this:

FROM dunglas/frankenphp:1-php8.4

COPY . /app
WORKDIR /app

RUN install-php-extensions @composer pdo_pgsql redis intl opcache \
    && composer install --no-dev --optimize-autoloader \
    && php artisan config:cache route:cache view:cache

ENV SERVER_NAME=":8080"
CMD ["php", "artisan", "octane:frankenphp", "--workers=4", "--max-requests=500"]

That’s the whole production setup. No supervisord. No FPM socket. No Nginx upstream block. The first time I deployed it I kept looking for the thing I’d missed.

The first thing that broke, and how it always breaks

The bug I hit twice in two different apps was the same shape. A service container singleton that held request-scoped data.

Picture a TenantContext class registered as a singleton in a service provider. Under FPM that’s safe because the container is torn down at the end of every request. Under Octane it’s not. The singleton survives. So request 1 sets tenant_id = 42, the worker stays warm, request 2 comes in for a different tenant, and your code happily reads 42 because the singleton never got reset.

The fix is simple once you see it. Move tenant-scoped state out of singletons:

// Before: silent cross-tenant leak under Octane
$this->app->singleton(TenantContext::class, function () {
    return new TenantContext(auth()->user()->tenant_id);
});

// After: re-resolves per request
$this->app->bind(TenantContext::class, function () {
    return new TenantContext(auth()->user()->tenant_id);
});

Or, better in my opinion, use Octane’s RequestReceived listener to reset the state explicitly. The Octane docs page on managing memory leaks lays out the pattern. Read it once before you migrate anything serious.

The other one that bit me was statics. Any static $foo in a helper class is now effectively a singleton between requests. If you cache anything in a static, you’re shipping a leak. I grep for static \$ before every Octane migration now. It’s a fast audit.

When I leave PHP-FPM in place

Not every app needs Octane. The ones where I left FPM running.

A back-office CRUD app that maxes out at 20 requests per minute. The framework boot cost was already fine. Adding worker-mode complexity for a service nobody is hammering buys nothing.

A Laravel app that depended on a couple of third-party packages still calling die() deep in the request flow. Under FPM, die() ends the request. Under Octane, it kills the worker. Fixing those packages was more work than I wanted to take on.

Anything with truly massive memory per request, like big PDF generation or full-resolution image processing. Workers grow to fit the largest request they ever served and don’t shrink. A single 600MB upload bloats your whole pool until you cycle.

The heuristic I now use: if a request is fast and you have a lot of them, Octane is worth it. If a request is huge or rare, it isn’t.

The actual numbers I got

I’m always skeptical when blog posts quote perf gains, so here are the numbers from one of my apps. A multi-tenant SaaS dashboard with about 40 routes, Postgres on the backend, Redis for cache and queue. Same EC2 instance, same Postgres, same Redis. Just swapped the runtime.

PHP-FPM 8.3 Octane + FrankenPHP 8.4
p50 latency 92 ms 41 ms
p99 latency 410 ms 185 ms
Requests / sec 240 580
Memory per worker n/a (per-req) ~120 MB resident

That’s not a Hello World benchmark. It’s a real app with auth, eager-loaded relations, and a few middleware that hit Redis. The p99 number is what justifies the migration for me. P50 cutting in half is nice. P99 dropping by more than half on the same hardware paid for the migration in two weeks of reduced infra cost.

When I tried the same migration on a Symfony app last year using FrankenPHP’s worker mode directly, I got similar numbers. About a 2x p99 improvement. The win isn’t Octane-specific. It’s “stop reloading the framework for every request”. Octane just packages the Laravel-flavored ergonomics around it.

What I’d do on day one if I were starting over

A short checklist of things I wish someone had told me up front.

Audit your singletons first, before you change a single line of runtime config. Grep singleton( in app/Providers/. Anything that touches request-scoped data needs to be re-bound or reset on RequestReceived.

Grep for static \$ in your app code. Same risk surface.

Set max_requests to something modest. I use 500. It cycles workers and limits the blast radius of any leak you missed. The cost is one slow boot every 500 requests, which is invisible at scale.

Use Octane’s concurrent task helper for fan-out work. Octane::concurrently([...]) lets you run independent jobs in parallel inside one request without queuing. I converted a dashboard that fired five API calls serially into one concurrent call and dropped that page’s p50 from 1.4s to 380ms.

Watch memory in the first 48 hours. I add a worker-memory metric to the Grafana setup I describe in my work pages and watch for monotonic growth. If memory climbs and never plateaus, you have a leak.

A lot of the production instincts I rely on for Octane came out of operating long-lived workers in other contexts. I wrote about the Laravel queue defaults I stopped trusting in production last month, and a lot of the same thinking applies to Octane workers. Long-lived PHP processes want different defaults than the per-request model most of us grew up on.

If you can spare a day, the smallest useful experiment is this. Clone your staging environment, switch one service to octane:frankenphp, run your real traffic through it for an afternoon with max_requests=500. You’ll know within a few hours whether your app is Octane-clean or whether you’ve got a leak to chase. That’s the call I should have made eight months ago.