Laravel Octane + FrankenPHP: What Actually Breaks (I Reproduced Every Leak)
Table of Contents
Octane keeps your Laravel application in memory between requests. That is where the speed comes from, and it is where everything that breaks comes from. Almost every “Octane gotchas” article repeats the same warning: a singleton that receives the request in its constructor captures the first request forever. I built a Laravel 13 app on Octane with FrankenPHP in Docker and reproduced each failure with curl, and that warning is wrong as usually stated. The leaks that do happen come from somewhere else.
Three results surprised me. A request-capturing singleton resolved during a request does not leak, because Octane handles every request on a clone of the application. --max-requests=0, which means “unlimited” on Swoole and RoadRunner, stops FrankenPHP from starting at all. And the features the docs mark as Swoole-only don’t throw on FrankenPHP: most of them silently do something else. Everything below comes with the command and the output.
If you haven’t decided whether Octane is worth it for your app, start with the Laravel performance guide; this post is for the step after “let’s turn it on”.
The lab
| Component | Version |
|---|---|
| Laravel | 13.32.0 (skeleton requires PHP ^8.3) |
| laravel/octane | 2.19.1 |
| FrankenPHP | 1.12.7, PHP 8.5.10 ZTS, Caddy 2.11.4 |
| Image | dunglas/frankenphp:latest (Debian 13) + pcntl + Composer |
| Load tool | wrk, pinned to separate CPUs |
A fresh skeleton, nine small classes and a route file. Octane runs with php artisan octane:frankenphp; the comparison mode is FrankenPHP’s classic php-server, which boots Laravel on every request the way PHP-FPM does.
Before it even starts: pcntl, zip and the musl binary
Three things got in the way before the first request:
-
The official FrankenPHP image has no
pcntl. Without it,octane:frankenphpdies immediately:Error Undefined constant "Laravel\Octane\Commands\Concerns\SIGINT" at vendor/laravel/octane/src/Commands/Concerns/InteractsWithServers.php:174The Dockerfile in the Octane docs runs
install-php-extensions pcntlfor exactly this reason. Copy that line. -
It has no
ziporunzipeither, socomposer requirefails inside it. Install dependencies in a Composer stage and copyvendor/over. -
On Alpine,
octane:installdownloads the musl binary. When there’s nofrankenphpon the PATH, Octane downloads one, and it picks the glibc build only ifgetconf GNU_LIBC_VERSIONsucceeds. On Alpine it doesn’t, so you get the 173 MB musl build. FrankenPHP’s own performance guide says to “avoid musl in production” because PHP is slower on it, especially in ZTS mode. On my trivial benchmark route I couldn’t measure a difference (5,900 req/s against 5,800-6,100 on glibc), but the known-issues page lists concrete gaps, such asGLOB_BRACEnot being available. Prefer the Debian images.
1. Singletons: the leak everyone describes isn’t the one that bites
The binding every article uses as its example:
// AppServiceProvider::register()
$this->app->singleton(RequestEcho::class,
fn (Application $app) => new RequestEcho($app['request']));
Resolved inside a route, with one worker so every request hits the same one:
?user=alice → {"query_user":"alice","singleton_sees":"alice"}
?user=bob → {"query_user":"bob","singleton_sees":"bob"}
?user=carol → {"query_user":"carol","singleton_sees":"carol"}
No leak. The reason is in Octane’s Worker::handle(): every request runs on $sandbox = clone $this->app, and a singleton resolved on that clone dies with it at the end of the request. The famous warning is true only in two situations, and they’re what you should be looking for.
The singleton is resolved during boot. The Octane docs phrase it precisely: the problem is when the instance “is resolved during the application boot process”. I resolved the same class in boot(), as an eager package would:
?user=alice → {"query_user":"alice","singleton_sees":null,"singleton_url":"http://localhost:8000"}
It doesn’t capture the first request. It captures the fake console request that Laravel binds while booting (URL = APP_URL, no input), and keeps it for every request.
Something resolves it through the base application. This is the one that really captures the first request. A listener registered in boot() that resolves lazily through $this->app, which is the original application Octane clones, not the clone:
// AppServiceProvider::boot(). Nothing is resolved here yet.
Event::listen('lab.who',
fn () => $this->app->make(RequestEchoViaProvider::class)->user());
?user=alice → "alice"
?user=bob → "alice"
?user=carol → "alice"
The first request resolved the singleton on the base app, and it stayed there for the life of the worker. Adding the class to flush in config/octane.php fixed it (alice, bob, carol).
The same mechanism turns stateful singletons into a data leak between users. A CurrentTenant singleton that lives on the base app (because something resolved it at boot), with a middleware that sets it only when the request carries X-Tenant:
-H 'X-Tenant: acme' → {"singleton_tenant":"acme","scoped_tenant":"acme"}
(no header) → {"x_tenant_header":null,"singleton_tenant":"acme","scoped_tenant":null}
The anonymous request inherited acme. With four workers it’s worse to debug: only the worker that served acme leaked, so one request in four came back wrong. The same class registered with scoped() instead of singleton() returned null on every request without the header.
What to do:
- Use
scoped()for anything that holds per-request state (tenant, current user, locale). - Don’t resolve request-dependent services in
boot(), and look for listeners and macros registered inboot()that call$this->app->make(). - For anything you can’t change (a package), add it to
flushinconfig/octane.php. - For services that need the request or config, inject a resolver,
fn () => Container::getInstance(), instead of the object.
2. Static state grows until the worker restarts
A static array that gets 10 KB per request, which is what a naive in-memory cache or a registry of “already processed” items does:
req#1 worker dcb87b items 1 mem_kb 5667
req#100 items 100 mem_kb 7001
req#300 items 300 mem_kb 9410
req#500 items 500 mem_kb 11810
req#501 worker c5125d items 1 mem_kb 1253 ← restart at --max-requests=500
About 12 KB per request, reset only when Octane restarts the worker after 500 requests, which is the default. The restart doesn’t appear in the logs, and it costs little: 1.85 ms before, 4.40 ms on the first request of the new worker, 2.19 ms after.
A trap when you test this yourself: my first version used str_repeat('x', 10240) and memory stayed flat over 500 requests. OPcache folds that constant expression into a single interned string, so every array item pointed at the same memory. With Str::random() the leak showed. If your leak test comes back clean, check that the data is actually different on each request.
3. --max-requests=0 doesn’t mean “unlimited” on FrankenPHP
On OpenSwoole, max_request 0 means no limit, and on RoadRunner “zero (or nothing) means no limit” for max_jobs. On FrankenPHP:
php artisan octane:frankenphp --max-requests=0
Error: loading initial config: … frankenphp app module: start: failed to initialize workers:
too many consecutive failures: worker public/frankenphp-worker.php has not reached frankenphp_handle_request().
The container exits with code 1. Octane’s worker script loops while ($requestCount < $maxRequests …), so with 0 it never serves a request; FrankenPHP sees six consecutive workers fail to reach frankenphp_handle_request() and gives up. Through octane:start --server=frankenphp --max-requests=0 the server does start, but the worker gets MAX_REQUESTS=500: a ?: in the command replaces the 0 with the config default without telling you. The two pull requests that would have made 0 mean unlimited were closed without merging.
If you want effectively unlimited, pass a large number (--max-requests=1000000 measured the same throughput as 500). But the restarts are what protect you from section 2, so I’d keep the default.
4. Config injected into a singleton goes stale, and can poison the worker
A singleton that receives the config repository in its constructor, and a request that changes a value at runtime (per-tenant config, say):
?set=changed-in-request → {"config_helper":"changed-in-request","injected_repo":"original",
"resolver_closure":"changed-in-request"}
The injected repository is the base app’s, so within the request it doesn’t see the change. The worse case is the reverse: a request that writes through that injected repository ($service->config->set(...)) changes the base app’s config, and from then on every later request on that worker reads mutated-via-service. In classic mode none of this survives the request. The fix is the one the docs give: inject a resolver closure, or call config() when you need the value.
5. What FrankenPHP adds: threads, $_ENV and exit()
Threads, not processes. Four workers all reported the same PID, and /proc showed a single frankenphp process with 67 threads. FrankenPHP’s docs say so (“threads instead of processes”), and it matters for two reasons: extensions that aren’t thread-safe can’t be used (the known-issues page lists imap, newrelic and pcov, and warns that imagick in the Docker images can crash because of its OpenMP threads), and a crash in any thread takes down the whole process, every worker included.
$_ENV and putenv() survive between requests. FrankenPHP resets $_GET, $_POST, $_SERVER and the rest, but its worker docs say “$_ENV is currently not reset between requests”. I tested it with putenv():
?v=secret-from-alice → {"prev_getenv":null}
(next request) → {"prev_$_ENV":"secret-from-alice","prev_$_SERVER":null,
"prev_getenv":"secret-from-alice"}
With four workers, only the thread that set it saw the value. If any code writes request data into the environment (some SDKs set credentials that way), it leaks to whoever that thread serves next.
exit() returns 200. exit(0) and exit(1) both sent the output so far with HTTP 200 and rebooted the worker. Twelve exit(1) in a row: twelve 200s, and the server survived. dd() returned 500 and also rebooted. A health check that only looks at the status code won’t see a script that dies halfway.
The 30-second limit is per request. Octane sets max_execution_time to 30 seconds. Two 20-second requests in a row on the same worker both returned 200; a 35-second one returned 500 after 31.2 s with “Maximum execution time of 30 seconds exceeded” in the log, and the worker rebooted.
Open issues worth knowing before you deploy (from php/frankenphp, still open in September 2026):
- #2588: Octane worker segfault (exit 139). The reporter fixed it by switching
opcache.jitfromtracingtofunction, pointing at a tracing-JIT regression in PHP 8.5.5 and later. - #1074: concurrent file uploads hang over HTTPS; the maintainer reproduced it in July with 30 multipart uploads over one HTTP/2 connection.
- #2388: the Alpine image segfaults with hundreds of environment variables (Kubernetes service links are the usual source).
And update Octane: before 2.18.0, a multipart/form-data POST without a boundary killed the request. With the old worker script I got a silent 500 with nothing in laravel.log; on 2.19.1 it’s handled normally.
6. The Swoole-only features fail silently
The docs mark concurrent tasks, ticks, the Octane cache and tables as Swoole features. What they do on FrankenPHP:
| Feature | On FrankenPHP |
|---|---|
Octane::concurrently() | Runs the tasks one after another: two 300 ms tasks took 600 ms |
Cache::store('octane') | Works, but it’s a separate cache per worker: a value written on one of four workers was missing on the other three |
Octane::tick() | Registers without error and never fires |
Octane::table() | Throws “Tables may only be accessed when using the Swoole server.” |
Only the last one tells you. If you’re moving from Swoole to FrankenPHP, grep for the first three: the code will keep running, just not doing what it did.
7. Packages: mostly fine in 2026
Octane ships its own reset listeners for Livewire, Inertia, Scout and Socialite. Current versions of the usual suspects handle it themselves: Filament resets its component managers on each request, Telescope listens to Octane’s request events, Debugbar 4 “works out of the box with Octane” (if you’re coming from 3.x, remove its entry from flush in config/octane.php), and Inertia 3 fixed its dev tools under Octane. The one to configure is spatie/laravel-permission: its Octane reset listener is off by default, and its docs say to turn it on if cached permissions look stale or cross between requests.
Is it worth it? The numbers
A laptop micro-benchmark, not a production claim: a route that returns a small JSON body, APP_ENV=production, the server pinned to 4 CPUs (8 workers) and wrk on separate CPUs, 20 seconds per run.
| Mode | Connections | Requests/s | Median latency |
|---|---|---|---|
| Octane (FrankenPHP worker) | 16 | ~6,000 | 2.5 ms |
FrankenPHP classic, with optimize | 16 | ~2,900 | 5 ms |
| FrankenPHP classic, without config/route cache | 16 | 1,345 | 11 ms |
| Octane | 64 | ~5,150 | 12 ms |
FrankenPHP classic, with optimize | 64 | ~2,490 | 24 ms |
php artisan serve | 64 | ~315 | 204 ms |
Octane doubled throughput against a properly cached classic setup, and gave 4.5 times more against one without php artisan optimize. The honest comparison is the first: if your production doesn’t cache config and routes, fix that before reaching for Octane. And on a real app the gain depends on how heavy your boot is compared to your I/O; a route that waits 200 ms on the database gains little from saving 3 ms of boot.
The checklist before you switch
# Services that hold state or receive request/config in the constructor
grep -rnE "singleton\(|->instance\(" app/Providers/
grep -rnE "__construct\([^)]*(Request|Repository|Container)" app/
# Static properties that accumulate
grep -rnE "static (array|\\\$)" app/ | grep -v "function"
# Resolution through the base app from boot()
grep -rnE "\\\$this->app->(make|get)\(" app/Providers/
# Things FrankenPHP treats differently
grep -rnE "putenv\(|\\\$_ENV\[|\bexit\(|\bdie\(" app/ routes/
# Swoole-only APIs that degrade silently
grep -rnE "Octane::(concurrently|tick|table)|store\('octane'\)" app/
Then, in order: install pcntl in the image, use a Debian-based image, run your test suite, load-test the Octane server with at least four workers (single-worker tests hide the leaks that only happen on one worker in four), and keep --max-requests at its default. If you’re also moving to Laravel 13, what breaks upgrading to Laravel 13 covers the framework side.
Frequently asked questions
Does a singleton that receives the request leak between requests in Laravel Octane?
Not if it is resolved during a request: Octane handles each request on a clone of the application, and singletons resolved on the clone are discarded at the end. Tested on Laravel 13.32 with Octane 2.19.1 and FrankenPHP 1.12.7. It leaks when it is resolved during boot (it captures the console request Laravel binds while booting) or through the base application, for example from a listener registered in boot() that calls $this->app->make(); in that case it keeps the first request for the life of the worker. Use scoped(), add the class to flush in config/octane.php, or inject a resolver closure.
What does --max-requests=0 do with Octane and FrankenPHP?
It stops the server from starting. With php artisan octane:frankenphp --max-requests=0, the worker script never reaches frankenphp_handle_request() and FrankenPHP exits with "too many consecutive failures". With octane:start the server starts but silently uses the default of 500. On Swoole and RoadRunner, 0 means unlimited. Use a large number if you want to avoid restarts, but the default 500 is what clears memory leaks.
Which Octane features do not work with FrankenPHP?
Concurrent tasks, ticks, the Octane cache and tables are Swoole features. On FrankenPHP, Octane::concurrently() runs tasks sequentially, the octane cache store is a separate array per worker, Octane::tick() registers but never fires, and only Octane::table() throws an exception. The first three fail silently.
Why does Laravel Octane fail with Undefined constant SIGINT on FrankenPHP?
Because the official dunglas/frankenphp Docker image does not include the pcntl extension, and Octane uses its signal constants. Install it with install-php-extensions pcntl, as the Dockerfile in the Octane documentation does. The image also lacks zip and unzip, so install Composer dependencies in a separate stage.
Is $_ENV reset between requests in FrankenPHP worker mode?
No. FrankenPHP resets $_GET, $_POST, $_COOKIE, $_FILES, $_SERVER and $_REQUEST between requests, but its documentation says $_ENV is currently not reset. In testing, a value set with putenv() in one request was visible to the next request on the same thread. Do not store request-specific or sensitive data in the environment.
How much faster is Laravel Octane with FrankenPHP?
In a laptop micro-benchmark with a minimal JSON route, Octane served about 6,000 requests per second against about 2,900 for FrankenPHP classic mode with config and route caches, roughly double, and 4.5 times more than classic mode without caches. The real gain depends on how much of each request is framework boot versus waiting on the database or other I/O.
Found it useful? Get the next one by email
Once a week: what breaks when you upgrade, AI for developers and what I'm building, with sources. No spam.
By subscribing you accept our privacy policy.