The same Laravel app, served three ways. Same code, same database, same box.
The only difference is what happens between "request arrives" and "your
controller runs", and that difference is worth 3.5x.
What each one actually does per request
PHP-FPM (processes)
request -> nginx -> FastCGI -> a worker PROCESS picks it up
-> execute index.php from opcache
-> boot Laravel (container, providers, config, routes)
-> your controller
-> throw the app state away, worker waits for next request
One process = one request at a time. The pool grows and shrinks
(pm=dynamic: fork when idle < min_spare, kill when idle > max_spare).
Every request pays the full framework boot.
FrankenPHP classic (threads)
request -> Caddy (same binary) -> a THREAD picks it up
-> execute index.php from opcache
-> boot Laravel <- still pays this
-> your controller
-> throw the app state away
Threads instead of processes: no fork cost, shared opcache, one binary with
the web server built in. But the app still boots per request, so per-request
work is nearly the same as FPM. Thread count defaults to 2x CPU cores.
Octane worker (kept-warm)
boot ONCE: Laravel container, providers, config, routes -> keep in memory
request -> Caddy -> a warm worker
-> your controller <- straight to it
-> reset request state, keep the app
The framework boot happens once per worker lifetime, not once per request.
Requests only pay for their own work.
The numbers (same endpoint, 50 VUs, 30s, zero failures)
| runtime | throughput | avg latency |
|---|---|---|
| PHP-FPM | 723 rps | 69 ms |
| FrankenPHP classic | 618 rps | 81 ms |
| Octane worker | 2,516 rps | 20 ms |
The gap between FPM and classic is small: opcache already removed the
compile cost, so both are dominated by the same framework boot. The gap to
the worker is the boot itself: that is what "keep the app in memory" buys.
How to prove which mode you are in
A static counter survives requests only if the app survives requests:
Route::get('/runtime', function () {
static $served = 0;
$served++;
return ['requests_served_by_this_worker' => $served];
});
- FPM: always 1
- classic: always 1
- worker: 1, 2, 3, 4... across calls
If the counter climbs, the app never died between requests. Simplest worker
mode debug tool there is.
Sizing: the two formulas
-
FPM scales by memory:
max_children = (RAM for PHP x 0.9) / avg process size(each worker is a full process, ~37 MB for a Laravel API, 250+ MB for a heavy CMS) -
Workers scale by cores:
workers = 2 x CPU coresas the starting point (io-heavy apps 2-4x, pure cpu 1x). Threads = workers + a few spare for statics.
Different resource, different formula. Mixing them up is a common mistake.
What it costs you: state persists
In worker mode the app never dies, so anything static or global carries over
between requests: memoized singletons, static caches, leaking event
listeners. Laravel and Octane reset the framework side, but your own statics
are your problem. This is THE tradeoff for the 3.5x.
Both runtimes also recycle workers on purpose (FPM pm.max_requests, Octane
--max-requests): a leak cannot grow forever if the worker does not live
forever.
When to use which
- FPM: the default, battle-tested, per-request isolation means no state bugs, fine for most apps
- FrankenPHP classic: FPM ergonomics (boot per request, no state risk) with a single-binary deploy and built-in web server
- Octane worker: when latency and throughput matter and you are willing to own the state discipline - the 3.5x is real
Top comments (0)