vs. PHP-FPM
ZealPHP replaces Apache + PHP-FPM with a single OpenSwoole process. Per-request, here's what that actually costs you.
App::mode('mixed'). That's PHP-FPM's exact execution model: one request at a time per warm worker, native $_GET/$_POST/$_SESSION, in-process — minus the FastCGI socket hop and minus the separate web server (the HTTP server is built in). Same model, fewer moving parts, faster. App::mode('coroutine') (the default) goes further — sub-millisecond, thousands of concurrent connections per worker — but it's a different execution model, so it's the bonus path, not the FPM comparison. App::mode('legacy-cgi') exists only for unmodified WordPress/Drupal and other define()-heavy code: it dispatches each public/*.php through the default cgiMode('pool') — a warm, pre-spawned PHP worker pool at ~1–3 ms/request — cgiMode('proc') for on-demand subprocess spawn (~30–50 ms, cold-start), the experimental cgiMode('fork') (Apache MPM prefork runner, fresh child per request at ~1 ms fork cost — see below), or cgiMode('fcgi') to forward to an external php-fpm pool.
The two architectures
Apache + PHP-FPM
browser
↓ TCP
Apache (or nginx)
↓ FastCGI / Unix socket
PHP-FPM pool master
↓ pick idle worker
PHP-FPM worker process
↓ load opcache, restart $_GET/$_POST
your script.php
↓ return body
PHP-FPM worker (idle again)
↓ back via FCGI
Apache → browser
Two processes per request (httpd + fpm worker), at least one socket hop, FCGI framing on every body chunk.
ZealPHP — App::mode('mixed') (FPM-equivalent)
browser
↓ TCP
OpenSwoole HTTP server (master + workers)
↓ pick idle worker (one request at a time)
ResponseMiddleware (in-process, no fork)
↓ $_GET/$_POST/$_SESSION populated, warm interpreter
your script.php (in-process include)
↓ return body
OpenSwoole writes response on the same socket
↓
browser
Same execution model as FPM — sequential per warm worker, native superglobals — but no FastCGI socket hop and no web server needed to bridge HTTP ↔ FastCGI (you'll still front it with a reverse proxy for TLS + scaling in production). The interpreter is pre-loaded; nothing forks per request. Switch to App::mode('coroutine') (the default) and the same worker handles thousands of concurrent connections — the bonus fast path, covered below.
Per-request cost matrix
Three stacks, three workloads. Costs are per-request, with the request body kept small so we're measuring the framework, not the bandwidth.
The App::mode('mixed') column is the apples-to-apples FPM comparison (same sequential execution model). App::mode('coroutine') is the different-model bonus path.
| Workload | Apache + PHP-FPM | ZealPHP mode('mixed')(FPM-equivalent) |
ZealPHP mode('coroutine')(bonus path) |
ZealPHP mode('legacy-cgi')(pool/fcgi) |
|---|---|---|---|---|
| JSON endpoint (no DB) | ~1–3 ms (FCGI hop) | ~1 ms (in-process, no hop) | < 0.1 ms (coroutine) | ~1–3 ms (warm pool) |
| Static template render | ~1–3 ms + opcache | ~1 ms (warm interpreter) | ~0.1 ms | ~1–3 ms (warm pool) |
Legacy app (native $_SESSION) |
~40–80 ms (mod_php) | same, in-process, no fork | use $g->X (no superglobals) |
~40–80 ms (warm pool worker) |
| SSE stream (long-lived) | 1 worker pinned | 1 worker pinned (scheduler off) | 1 coroutine — worker free | 1 pool worker pinned |
| WebSocket connection | Not supported | Native — same process | Native — same process | Native (handler in coroutine) |
Note the honest trade-off: in App::mode('mixed') the coroutine scheduler is off, so a long-lived SSE stream pins its worker exactly like FPM. That's the one place App::mode('coroutine') clearly wins — and why it's worth switching to it for new code that does streaming or concurrent I/O.
Ranges, not point measurements — actual numbers depend on kernel, CPU, opcache state, and which exact FPM tuning you've done. The bench script at scripts/bench_vs_fpm.sh runs the JSON workload on the local box; reproduce before quoting.
PHP interpreter lifecycle — every path keeps the interpreter warm
Apache and FPM keep the PHP interpreter alive across requests — so do all of ZealPHP's modes. App::mode('mixed') runs your code in-process in the warm worker; App::mode('legacy-cgi') dispatches each public/*.php through a warm, pre-spawned PHP worker pool (cgiMode('pool'), the default) so the interpreter stays in memory while still giving define()-heavy WordPress / Drupal true global-scope isolation per request. The spectrum:
| Stack | PHP interpreter lifecycle | Per-request startup cost |
|---|---|---|
| Apache + mod_php | PHP loaded INTO the Apache worker as a shared library. Interpreter already in memory when request arrives. | ~0 ms (no spawn) |
| nginx + PHP-FPM | Pool of pre-forked, long-lived PHP workers. Each handles M requests then recycles (pm.max_requests). nginx hands the request to an idle worker via FastCGI socket. |
~1–3 ms (FCGI handshake) |
ZealPHP App::mode('mixed') — processIsolation(false) |
In-process include into the warm, long-lived OpenSwoole worker. No fork, no exec — the interpreter is already in memory, exactly like mod_php. This is the FPM-equivalent execution model with native $_SESSION (v0.2.27). |
~0 ms (no spawn) |
ZealPHP App::mode('legacy-cgi') — cgiMode('pool') (default) |
N pre-spawned PHP worker processes, warm interpreter, global scope reset between requests, async dispatch via Coroutine\Channel. Auto-respawn on worker death; recycle after App::cgiPoolMaxRequests() (default 500). FPM-equivalent recovery semantics. |
~1–3 ms (FPM territory) |
ZealPHP App::mode('legacy-cgi') — cgiMode('fork') (experimental) |
Apache MPM prefork runner. A long-lived fork-master (src/fork_master.php) binds a UNIX socket and forks a fresh child per request that runs the target .php at true global scope, then hard-exits. Fresh-process correctness (no "Cannot redeclare class") for unmodified WordPress/legacy apps. Requires pcntl + posix. Set via App::cgiMode('fork') — there is no App::mode() preset for fork. Concurrency capped by App::$cgi_fork_max_concurrent (default 16); returns 503 when full. |
~1 ms (fork cost) |
ZealPHP App::mode('legacy-cgi') — cgiMode('fcgi') |
Forwards each public/*.php to an external FastCGI / php-fpm pool over the same wire protocol nginx's fastcgi_pass uses. ZealPHP becomes the HTTP / WebSocket / coroutine layer; your existing FPM pool stays the PHP runtime. |
your FPM pool + 1 local socket hop |
So the legacy path isn't slow because legacy code is slow — every option here keeps the interpreter warm. Apache and FPM amortise PHP's startup by keeping the interpreter alive across requests; cgiMode('pool') does exactly that (warm worker pool + per-request global scope, async dispatch), and cgiMode('fcgi') reuses a pool you already run.
cgiMode('pool')
Pool is the default CGI mode under App::mode('legacy-cgi'). N pre-spawned PHP worker processes start with the server, stay warm between requests, and reset global scope per request — mod_php-style isolation, interpreter always in memory. Dispatch is async via the parent worker's Coroutine\Channel, so the worker stays non-blocking while the pool worker runs the file. Target latency: ~1–3 ms (FPM-equivalent territory). Workers that die are auto-respawned; each recycles after App::cgiPoolMaxRequests() requests (default 500, mirrors FPM's pm.max_requests). Configure pool size with App::cgiPoolSize(N).
Alternative — cgiMode('fcgi'): forwards each file to an existing php-fpm pool over FastCGI — ZealPHP runs no PHP itself in this mode. See /legacy-apps#cgi-mode-fcgi.
What App::mode('legacy-cgi') buys you
This mode exists so unmodified WordPress, Drupal, and other define()-heavy code that assumes a fresh process per request just works. App::mode('legacy-cgi') sets superglobals(true) + processIsolation(true) and dispatches each public/*.php through the warm cgiMode('pool') worker pool — native $_GET/$_SESSION, per-request global scope, ~1–3 ms per request, the interpreter never leaves memory. Point it at an external php-fpm pool instead with cgiMode('fcgi').
The split is clean: only the legacy public/*.php files go through the pool. Everything else stays in-process at sub-millisecond cost:
- Routes you define via
$app->route()run in-process — sub-millisecond, no pool dispatch. - ZealAPI endpoints (
api/*.php) run in-process — no pool dispatch. - Middleware (CORS, ETag, sessions, rate limit) runs in-process — no pool dispatch.
- WebSocket, SSE, streaming, timers — all in-process — no pool dispatch.
- You can mix modes per app via the lifecycle setters (v0.2.23) underneath the
App::mode()presets.
In other words: legacy files run on the warm pool; everything new you write runs on the in-process fast path. With FPM, every request — new code, old code, an API health check, a static asset proxy — pays the FCGI hop.
The simplest path: App::mode('mixed') = an FPM pool, minus the operations
Here's the part most people miss: if your legacy app doesn't actually need fresh-process-per-request isolation, you don't need legacy-cgi at all. One line — App::mode('mixed') — and ZealPHP becomes a PHP-FPM pool that happens to speak HTTP natively. Your code runs in-process in the warm worker, no FastCGI socket hop, and no separate web server just to translate HTTP ↔ FastCGI the way nginx does for an FPM pool. You'll still put a reverse proxy in front in production — see the note below.
<?php
// app.php — ZealPHP configured as a PHP-FPM-equivalent pool
use ZealPHP\App;
// One preset — the PHP-FPM-equivalent execution model:
App::mode('mixed'); // = superglobals(true) + processIsolation(false)
// + enableCoroutine(false) + hookAll(0)
// Native $_GET/$_POST/$_SESSION, in-process include,
// one request at a time per warm worker. No socket hop.
$app = App::init('0.0.0.0', 8080);
$app->run();
// Run with a worker count, exactly like pm.max_children:
// ZEALPHP_WORKERS=32 php app.php
| PHP-FPM | ZealPHP App::mode('mixed') |
|---|---|
pm.max_children = 32 | ZEALPHP_WORKERS=32 |
| Worker handles one request at a time | scheduler off — sequential per worker |
| Worker process stays warm between requests | OpenSwoole workers are long-lived |
| No PHP fork per request (interpreter pre-loaded) | in-process include, ~0 ms |
pm.max_requests = 500 (recycle for hygiene) | OpenSwoole max_request setting |
$_GET / $_SESSION native | Populated per request (v0.2.27) |
| Requires nginx/Apache in front — FPM can't speak HTTP | Speaks HTTP natively — a front proxy is optional for one node, recommended for TLS + scaling |
This is the apples-to-apples FPM comparison and ZealPHP wins it cleanly: same per-request execution model, same worker semantics, but no FastCGI socket hop and no web server needed purely to bridge HTTP ↔ FastCGI. Process isolation (App::mode('legacy-cgi')) is opt-in — it's the price of true process isolation per request, which only WordPress/Drupal-class apps with define() collisions actually need.
Production still wants a reverse proxy in front. ZealPHP speaking HTTP natively removes the web server you'd otherwise run just to translate FastCGI — it does not remove the reasons to run a real edge proxy. Put nginx, Caddy, HAProxy, or Traefik (or Apache as mod_proxy) in front for TLS termination and, above all, horizontal scaling: the proxy load-balances across a pool of ZealPHP nodes (round-robin / least-conn over an upstream of backend addresses), exactly as it would across a pool of FPM or Node services. One built-in HTTP server replaces the per-node web server; it does not replace the load balancer. See Deployment for the reverse-proxy setup.
Or: keep your FPM pool — let ZealPHP front it
If you've already invested in a tuned php-fpm pool (sized for your workload, hooked into your observability stack) and don't want to retire it, ZealPHP can speak FastCGI to it directly. Set App::cgiMode('fcgi') + App::fcgiAddress(...), and every public/*.php file gets forwarded to the upstream pool over the same wire protocol nginx's fastcgi_pass and Apache's mod_proxy_fcgi use. ZealPHP becomes the HTTP / WebSocket / coroutine layer; php-fpm remains the PHP runtime.
<?php
// app.php — front an existing php-fpm pool instead of running PHP in-process
use ZealPHP\App;
App::mode('legacy-cgi'); // superglobals(true) + processIsolation(true)
App::cgiMode('fcgi'); // 'pool' (default warm worker pool) | 'fcgi'
App::fcgiAddress('127.0.0.1:9000'); // or 'unix:/run/php/php-fpm.sock'
$app = App::init('0.0.0.0', 8080);
$app->run();
Throughput in this mode equals whatever your FPM pool delivers minus one local socket hop — we don't run PHP at all. That's why the measured table below intentionally omits the 'fcgi' row: the answer is "ask your FPM pool" and depends on pm.max_children, the file under load, and Unix-socket vs TCP. Full walkthrough + per-extension form (mix .py + .pl backends in the same app) lives at /legacy-apps#cgi-mode-fcgi.
How much does process isolation actually cost? On this box, running the same legacy file in-process via App::mode('mixed') hits 21,964 req/s; isolating it per request via App::mode('legacy-cgi') + cgiMode('pool') still keeps the interpreter warm at ~1–3 ms/request — nothing forks per request. The full measured breakdown (Apache mod_php, ZealPHP coroutine, mixed, and pool CGI, all on one machine) is in the measured table below.
mixed matches FPM's execution model, not FPM's per-request reset
PHP-FPM gives each request a fresh state even on a warm, reused worker: between requests it runs PHP's per-request executor shutdown (shutdown_executor()), which clears require_once'd files, request-declared classes/functions, function statics, and the global symbol table. App::mode('mixed') matches FPM's execution model — one request at a time per warm worker, native superglobals, in-process — but not that per-request reset. OpenSwoole keeps the worker's executor alive across requests, so define(), declared classes, the require_once'd bootstrap, and ini_set() from request N persist into request N+1 on the same worker.
For apps that don't rely on fresh-state-per-request — Composer-autoloaded, guarded define()s (Symfony, Laravel, most modern code) — that warm reuse is exactly what you want, and mixed is the fast, simple FPM replacement. But unmodified require_once apps that re-define() constants and rebuild top-level globals every request (WordPress, Drupal) need the reset — mixed renders them once and then degrades (e.g. Constant WP_USE_THEMES already defined + a blank body on the second request). Two modes provide the reset:
App::mode('legacy-cgi')— a fresh subprocess per request from the warm pool (mod_php / FPM parity), orcgiMode('fork')/cgiMode('fcgi').App::mode('coroutine-legacy')— re-implements the per-request reset in-process via ext-zealphp (re-executesrequire_once'd files, resets statics/classes, runs at true global scope). It now runs unmodified WordPress end-to-end including wp-admin, and adds coroutine concurrency on top.
SNA Labs took a different route — full coroutine mode on dev with an async Rust MongoDB driver; see the case study.
"PHP-FPM has N workers — does ZealPHP need more?"
No. Workers mean different things in the two architectures.
| Concept | PHP-FPM | ZealPHP |
|---|---|---|
| What a "worker" handles | One request at a time, start to finish | Many concurrent coroutines (hundreds), interleaved |
| Right worker count | ~CPU cores × 2, or higher for I/O-heavy apps to avoid blocking | ~CPU cores. I/O concurrency comes from coroutines, not extra workers |
| What happens on slow I/O | Worker blocked until I/O returns — concurrent requests queue | Coroutine yields — worker handles the next request immediately |
| Memory cost per worker | Full PHP runtime per worker (~50–200 MB each) | Full PHP runtime per worker — but you need fewer workers |
The intuition: an FPM pool of 64 workers can handle 64 concurrent requests. A ZealPHP setup with 8 workers and the coroutine scheduler can handle thousands of concurrent connections — most of them yielding on I/O at any given moment. The benchmark page (/performance) confirms it: 4 workers, 116k req/s through the full middleware stack.
Horizontal scaling — same playbook as FPM
PHP-FPM scales horizontally by adding more nodes behind a load balancer and sharing state in a backing store. ZealPHP scales the same way:
| Concern | Apache / FPM | ZealPHP |
|---|---|---|
| Per-node workers | pm.max_children = N |
ZEALPHP_WORKERS=N (each worker runs thousands of coroutines) |
| Add more nodes | nginx / Caddy / Traefik / HAProxy load-balances across N FPM pools | Same — load-balance across N php app.php instances behind the same reverse proxy |
| Cross-node session store | Redis / Memcached / DB session handler | StoreSessionHandler (Redis-backed) or any PSR-compatible session handler |
| Cross-node shared cache | Redis / Memcached | Store::defaultBackend(Store::BACKEND_REDIS) — same Store API, cross-node visibility (or 'tiered' for L1 local + L2 Redis with HMAC-signed invalidation) |
| Cross-node pub/sub | Sidecar (Socket.io / Pusher) or Redis pub/sub | Store::publish / App::subscribe (Redis pub/sub) or publishReliable (Redis Streams) — built-in |
| Cross-node WebSocket routing | Sticky sessions + sidecar (Socket.io / Centrifugo) | WSRouter — fd-owner registry in Redis, framework pushes to the node that holds the connection (/store#pubsub) |
| Database replicas / shards | Whatever your driver supports — PDO read/write split, ProxySQL, RDS replicas | Same — coroutine-friendly drivers yield on each query (PDO + HOOK_ALL, or coroutine-native MySQL clients) |
The model is identical. What changes is the per-node concurrency profile (FPM = N concurrent requests per node; ZealPHP = thousands per worker × N workers) and what state lives where (FPM almost always offloads to Redis from request one; ZealPHP can keep cross-worker hot state in-process on one node, then flip to Redis with one line of config when you scale out). Either way, the scaling primitives — load balancer in front, shared store at the back — are the same.
When PHP-FPM is still the right answer
Be honest about it — there are cases where Apache + PHP-FPM is the safer pick today:
- You're running off-the-shelf code that you can't modify and don't want to touch — a Drupal shop with 40 contrib modules, a WordPress site with 60 plugins.
App::mode('legacy-cgi')runs them, but FPM is the path of least surprise. - Your only PHP extensions are blocking ones with no coroutine equivalent. PDO is the canonical example (see the SNA Labs case study for how we hit this with MongoDB and built a replacement). If every database call blocks, the coroutine scheduler can't help you — and you might as well run FPM.
- Your hosting environment forbids custom PHP extensions entirely. ZealPHP requires
ext-zealphp(our own lightweight C extension); FPM doesn't. Shared hosting with no shell access = FPM wins by default.
ZealPHP's pitch is not "always faster than FPM." It's: same compatibility, plus a fast path for new code, plus WebSocket and SSE and timers without leaving PHP.
Production proof point
Selfmade Ninja Labs runs the same PHP codebase on both Apache (mod_php) and ZealPHP from a single Docker container, sharing one volume, one MongoDB instance, one Redis. Same source tree. 41 migration commits, 806 files touched, zero downtime, including a custom Rust MongoDB extension to replace the blocking PECL driver. Read the case study →
Measured: four ways to serve the same legacy file
These are real numbers, not illustrative. Same machine, same trivial public/probe.php (echo "ok"), 4 workers each, ab -n 3000 -c 20. The only thing that changes is which server / lifecycle mode serves the file. This is specifically the legacy-file-serving path (implicit public/*.php routing) — the workload that matters when you're migrating an existing app, not ZealPHP's native-route fast path.
| Stack | How the file runs | req/s | ms/req |
|---|---|---|---|
| Apache + mod_php | Interpreter loaded in-process, warm | 40,861 | 0.49 |
ZealPHP mode('coroutine') (default) |
In-process include, coroutine-per-request | 34,159 | 0.59 |
ZealPHP mode('mixed')FPM-equivalent |
In-process include, sequential per worker | 21,964 | 0.91 |
ZealPHP mode('legacy-cgi')cgiMode('pool') (default) |
Pre-spawned warm worker pool, async dispatch (estimate; bench script update in flight) | ~est. 1,000–3,000 | ~1–3 |
ZealPHP mode('legacy-cgi')cgiMode('fork') — experimental |
Fork-per-request (Apache MPM prefork runner); fresh child exits after each request — true fresh-process correctness. Requires pcntl + posix. Not yet in the bench script. |
— | ~1 ms (fork) |
Intel i9-14900K · PHP 8.3 · 4 workers each · ab -n 3000 -c 20. Apache served /probe.php; ZealPHP served /probe (extensionless implicit route). PHP-FPM wasn't installed on this box — Apache+mod_php is the warm-interpreter baseline and is actually faster than FPM would be (mod_php is in-process; FPM adds a FastCGI socket hop). Pool CGI row is an estimate; measured numbers pending bench script update. Reproduce with scripts/bench_vs_fpm.sh.
Experimental: App::mode('coroutine-legacy') (requires ext-zealphp) is a compatibility runtime that runs traditional request-style PHP — the PHP-FPM "fresh state per request" mental model — under OpenSwoole coroutine concurrency, isolating superglobals, $GLOBALS, statics, and define() per coroutine. It's not in the measured table above because it's still maturing; treat it as the experimental concurrent successor to legacy-cgi, not a benchmark baseline yet.
What these numbers actually say
- Don't isolate if you don't have to. Both in-process modes (
mode('coroutine')34k,mode('mixed')22k) are within striking distance of Apache mod_php (41k) and orders of magnitude past process isolation. If your legacy app doesn't need a fresh process per request, runApp::mode('mixed')and pay nothing. - If you DO need isolation,
App::mode('legacy-cgi')keeps the interpreter warm. The defaultcgiMode('pool')targets ~1–3 ms/request — same isolation, same superglobals, same per-request global scope, but the pool's PHP workers stay warm and dispatch is async via the parent worker's coroutines. Nothing forks per request. The experimentalcgiMode('fork')(Apache MPM prefork runner) forks a fresh child per request at ~1 ms cost — true fresh-process correctness without the ~30–50 msproc_openoverhead ofcgiMode('proc').cgiMode('fcgi')reuses an external php-fpm pool you already run. - Honest finding: Apache mod_php (41k) edges out ZealPHP on this trivial echo. For a no-I/O, no-middleware legacy file, a mature in-process C SAPI is hard to beat. ZealPHP's win shows up elsewhere — native routes (/performance), coroutine I/O concurrency, WebSocket, SSE, and not needing a separate web server at all. We're not going to pretend otherwise.
Reproduce locally
scripts/bench_vs_fpm.sh benches every row of the table above. It always hits the ZealPHP coroutine endpoint (ZEAL_URL); point the other knobs at instances you've started to fill in the rest:
MIXED_URL=— the apples-to-apples row: a ZealPHP instance runningApp::mode('mixed')(the PHP-FPM-equivalent in-process model).FPM_URL=— Apache/nginx + PHP-FPM serving the same trivial file.POOL_URL=— a ZealPHP instance runningApp::mode('legacy-cgi')with the defaultcgiMode('pool')(warm worker pool).FORK_URL=— a ZealPHP instance runningApp::mode('legacy-cgi')with the experimentalcgiMode('fork')(fork-per-request, Apache MPM prefork runner; requirespcntl+posix).FCGI_URL=— a ZealPHP instance runningApp::mode('legacy-cgi')withcgiMode('fcgi')fronting an external php-fpm pool.
It probes each first and skips with a setup hint if it can't reach one — it won't auto-install Apache/FPM.