DEV Community

Cover image for PHP-FPM vs FrankenPHP classic vs Octane worker
kevariable
kevariable

Posted on

PHP-FPM vs FrankenPHP classic vs Octane worker

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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];
});
Enter fullscreen mode Exit fullscreen mode
  • 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 cores as 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)