Comparing PHP Deployment Architectures
Compare Apache+mod_php, Apache+PHP-FPM, Nginx+PHP-FPM, Caddy+PHP-FPM, plain CLI PHP, RoadRunner, and FrankenPHP across process model, request lifecycle, deployment complexity, static-file handling, and scaling, and choose the right one for a given deployment scenario.
"How does PHP actually get run in production" has more than one right answer, and the answer has changed a lot over PHP's history. The classic answer -- Apache with mod_php baked in -- is still common on cheap shared hosting, but most modern high-traffic PHP runs on Nginx or Caddy talking to a separate PHP-FPM process pool, and a newer generation of runtimes (RoadRunner, FrankenPHP) throws out the "boot PHP fresh for every request" model entirely in favor of long-lived application state, promising major performance gains at the cost of new correctness traps. This lesson lays all seven side by side so you can reason about the actual tradeoffs instead of cargo-culting a stack.
Apache + mod_php vs Apache/Nginx/Caddy + PHP-FPM
mod_php embeds the PHP interpreter directly inside Apache's own worker processes -- there's no separate PHP process at all; each Apache worker (in the traditional prefork MPM) is itself a PHP-capable process. This is the simplest possible setup to get running (install, enable the module, done) and was the default on shared hosting for years, but it has real structural downsides: every Apache worker, even ones only ever serving a static image or a non-PHP request, carries the full memory footprint of a loaded PHP interpreter and all its extensions, and Apache's prefork MPM (required for mod_php, since PHP historically wasn't thread-safe enough for the threaded MPMs) is heavier per connection than event-driven models.
PHP-FPM decouples "the web server that terminates HTTP and serves static files" from "the pool of processes that execute PHP", communicating over FastCGI as covered in the previous lesson. Apache + PHP-FPM (via mod_proxy_fcgi) gets Apache's mature .htaccess/module ecosystem while still separating PHP into its own tunable, independently restartable, independently scaled process pool. Nginx + PHP-FPM is the most common production combination today: Nginx is a lean, event-driven server extremely efficient at serving static files and proxying, and it never itself runs PHP -- it purely routes .php requests to FPM. Caddy + PHP-FPM is a newer but increasingly popular variant: Caddy's main selling point is automatic HTTPS (it provisions and renews Let's Encrypt certificates with essentially zero config) on top of the same FastCGI-to-FPM handoff pattern, with a notably simpler config file syntax than Nginx's.
Across all four of these, the fundamental request lifecycle is the same one covered in the FPM-internals lesson: web server terminates the HTTP connection, static files are served (or proxied) directly by the web server, and only requests targeting PHP scripts get handed off, either as an in-process module call (mod_php) or over FastCGI to a separate pool (everything else).
• Apache + mod_php: PHP embedded in Apache workers; simplest setup, but heavier memory per connection and requires the prefork MPM. • Apache + PHP-FPM: Apache's ecosystem (.htaccess, modules) with PHP split into its own tunable process pool. • Nginx + PHP-FPM: lean, event-driven, most common modern production combo; Nginx never runs PHP itself. • Caddy + PHP-FPM: same FastCGI pattern as Nginx, with automatic HTTPS and simpler config as its headline feature.
CLI PHP, RoadRunner, and FrankenPHP: Breaking the Boot-Per-Request Model
Plain CLI PHP (php script.php) isn't a web-serving architecture by itself, but it's the foundation the others build on -- it's how cron jobs, queue workers, build scripts, and Composer itself run, and it's also how you'd run a raw built-in dev server (php -S localhost:8000) for local development. The built-in server is explicitly documented as unsuitable for production: it's single-threaded (one request at a time, full stop) and has none of FPM's process management, so it never belongs anywhere near a production deployment.
RoadRunner is a high-performance application server, written in Go, that runs your PHP application as a set of long-lived worker processes it manages -- but unlike FPM, RoadRunner's workers don't necessarily tear down and rebuild all application state on every single request. A RoadRunner worker can boot your framework's dependency-injection container once and reuse it across thousands of requests, which eliminates a huge chunk of the per-request bootstrap cost (autoloading, config parsing, container wiring) that FPM pays on every single request. It communicates with PHP over RPC and typically fronts your app directly or sits behind Nginx as a reverse proxy.
FrankenPHP is a newer PHP application server built on top of Caddy (in Go, embedding PHP directly via cgo), offering the same boot-once-serve-many worker mode as RoadRunner, but bundled as a single self-contained binary that is simultaneously a full web server (static files, automatic HTTPS, HTTP/2 and HTTP/3) and the PHP runtime -- there's no separate FPM process and no separate reverse proxy to configure. Both RoadRunner and FrankenPHP's worker mode deliver large throughput gains for frameworks that support it (Laravel Octane runs on either), but they reintroduce a class of bug that boot-per-request architectures made impossible: leftover global/static state between requests, because the same booted process now really does serve request after request without a full reset.
• CLI PHP: the base invocation for scripts, cron jobs, queue workers; php -S dev server is explicitly not for production. • RoadRunner: Go-based app server; PHP workers can persist bootstrapped state across requests via RPC, cutting per-request boot cost. • FrankenPHP: Caddy-based, single binary = web server + PHP runtime + optional worker mode; built-in automatic HTTPS. • Both RoadRunner and FrankenPHP worker mode trade the safety of full per-request teardown for speed -- global/static leftover state becomes a real risk your code must guard against.
Static Files, Reverse Proxying, TLS, and Buffering
Static file handling differs sharply across these. Nginx and Caddy are both excellent, highly optimized static file servers in their own right and should always be the thing serving images/CSS/JS directly rather than routing them through PHP. Apache + mod_php can also serve static files fine, but every request -- static or not -- still goes through an Apache worker that's carrying PHP's memory footprint, which is wasteful at scale. FrankenPHP, being Caddy underneath, also serves static files natively and efficiently without needing a separate front server.
TLS (HTTPS) termination is traditionally the web server's job -- Nginx, Apache, and Caddy all terminate TLS before proxying plaintext to FPM over the internal FastCGI link. Caddy and FrankenPHP both distinguish themselves by provisioning and renewing Let's Encrypt certificates automatically with zero manual config, which Nginx and Apache require external tooling (like Certbot) to do. Output buffering also differs by architecture: FPM workers buffer a request's full output internally before FastCGI streams it back (though PHP's own output buffering functions still apply on top of that), while worker-mode runtimes like RoadRunner/FrankenPHP stream more directly since there's no separate FastCGI hop -- the same Go process is both the HTTP server and the PHP host.
Scaling strategy is where these architectures diverge most in operational terms. Nginx/Caddy + FPM scale by adding more FPM pool capacity (tune pm.max_children, or add more app servers behind a load balancer) while the front web server layer stays comparatively cheap and stateless. RoadRunner/FrankenPHP scale by adding more persistent worker processes, but because those workers hold in-memory state across requests, horizontal scaling (more machines/containers) is straightforward while vertical worker-count tuning requires more care about memory growth per worker over its (much longer) lifetime.
• Static files: let Nginx/Caddy/FrankenPHP serve them directly -- never proxy static assets through PHP. • TLS termination: web server's job in all models; Caddy/FrankenPHP add automatic cert provisioning/renewal. • Buffering: FPM buffers per-request over FastCGI; worker-mode runtimes stream more directly, no FastCGI hop. • Scaling: FPM scales via pool sizing + more app servers; worker-mode runtimes scale via more long-lived workers, with closer attention to per-worker memory growth.
Tradeoff Comparison
"Boot once, reused" is a double-edged sword: it's fast, but it means global/static state and resource handles can leak across requests if the application isn't written with that in mind. FrankenPHP can also run in classic "one request per boot" mode (like FPM) if worker mode isn't wanted -- worker mode is opt-in, not forced.
Architecture Process Model Per-Request Boot Cost Best For
-----------------------------------------------------------------------------------------------
Apache + mod_php PHP embedded in workers Full boot every request Simple shared hosting
Apache + PHP-FPM Separate FPM pool Full boot every request Apache ecosystem + FPM tuning
Nginx + PHP-FPM Separate FPM pool Full boot every request Most common production default
Caddy + PHP-FPM Separate FPM pool Full boot every request Auto-HTTPS + simple config
CLI PHP (php -S / script) Single process, no pool N/A (one-shot / dev only) Scripts, cron, queue workers, local dev
RoadRunner Persistent Go-managed workers Boot once, reused High-throughput apps that support worker mode
FrankenPHP Persistent Caddy-embedded workers Boot once, reused Single-binary deploys, auto-HTTPS + speedCode Examples
This single script demonstrates the core architectural difference in this lesson: under FPM's boot-per-request model, every request starts from a clean interpreter, so a static property always starts at 0. Under RoadRunner/FrankenPHP worker mode the same process really does live across many requests, so static state silently accumulates -- correct behavior for a legitimate per-worker cache, a serious bug for anything that was supposed to be per-request scoped. This is exactly why frameworks that support worker mode (like Laravel Octane) ship explicit request-lifecycle reset hooks -- to reset container bindings, static caches, and other request-scoped state between requests inside a persistent worker.
public static int $count = 0;
php_sapi_name() reports which Server API PHP was invoked through -- 'cli' for the command line, 'fpm-fcgi' for any PHP-FPM deployment (Apache, Nginx, or Caddy all report the same SAPI here since FPM itself is what's running the interpreter), or an Apache module name for embedded mod_php. RoadRunner and FrankenPHP typically report their own custom SAPI names, since they embed the interpreter through their own integration layer rather than through the standard FPM or Apache module path. Expected result: PHP SAPI: fpm-fcgi Running under PHP-FPM (behind Apache, Nginx, or Caddy over FastCGI). Note that php_sapi_name() alone can't tell you whether FPM sits behind Nginx, Apache, or Caddy -- that distinction lives entirely in the web server's own config, not in anything PHP itself can observe.
<?php
// index.php -- imagine this is booted once and reused across
// many requests under RoadRunner/FrankenPHP worker mode.
// DANGEROUS under worker mode: a static property persists
// across every request served by this worker process.
class RequestCounter {
public static int $count = 0;
}
function handle(): string {
RequestCounter::$count++;
return "This worker has now handled " . RequestCounter::$count . " requests\n";
}
echo handle();
// Under classic FPM: $count resets to 0 on every request (fresh process state).
// Under worker mode: $count keeps climbing for as long as this worker lives --
// which is exactly the bug class worker mode introduces if you're not careful.<?php
// A small diagnostic to print which SAPI (Server API) is running --
// useful for confirming your architecture assumption is what you think.
echo "PHP SAPI: " . php_sapi_name() . PHP_EOL;
$sapi = php_sapi_name();
$description = match (true) {
$sapi === 'cli' => 'Running as plain CLI PHP -- no web server involved.',
$sapi === 'fpm-fcgi' => 'Running under PHP-FPM (behind Apache, Nginx, or Caddy over FastCGI).',
str_contains($sapi, 'apache') => 'Running embedded in Apache (mod_php).',
default => "Running under SAPI '$sapi' -- could be a custom/embedded runtime (e.g. RoadRunner, FrankenPHP).",
};
echo $description . PHP_EOL;Important Rules
Never serve static assets through PHP -- let the front web server (Nginx/Apache/Caddy) or FrankenPHP handle them directly.
Never use php -S (the built-in dev server) in production -- it is single-threaded and has no process management.
Choose FPM-based architectures by default; only reach for RoadRunner/FrankenPHP worker mode when your framework explicitly supports it and your code avoids leaking state across requests.
Prefer Caddy or FrankenPHP when automatic HTTPS provisioning is a priority and Nginx's extra config overhead isn't needed.
If adopting worker mode, audit for static properties, singletons, and any resource handle that assumes a fresh process per request.
Why This Matters in Production
The overwhelming majority of production PHP today runs Nginx + PHP-FPM (or increasingly Caddy + PHP-FPM) precisely because the boot-per-request model, while it pays a real bootstrap cost on every request, is extremely easy to reason about: there is never any leftover state to worry about, and any single request that misbehaves affects only that one request. RoadRunner and FrankenPHP worker mode are increasingly adopted for high-throughput APIs where that bootstrap cost -- container wiring, config parsing, autoloading -- is a measurable fraction of total request time, but teams that adopt them report the biggest early bugs are almost always state-leak bugs the old architecture made structurally impossible, which is exactly why this lesson calls that risk out explicitly rather than presenting worker mode as a free win.
Assuming code that works correctly under PHP-FPM will behave identically under RoadRunner/FrankenPHP worker mode with zero changes
Audit for static state, singletons, and long-lived resource handles before adopting worker mode, and reset request-scoped state explicitly between requests
FPM's per-request process teardown accidentally hides bugs (like a static counter, or a DB connection assumed fresh) that worker mode's persistent processes expose immediately -- state that used to reset for free now needs an explicit reset hook.
Running php -S 0.0.0.0:80 index.php as the production web server for a live site
Use Nginx, Apache, or Caddy in front of PHP-FPM (or a proper application server) for anything beyond local development
The built-in CLI server is explicitly documented by PHP as intended for development only -- it is single-threaded, has no worker pool, no process supervision, and will fall over immediately under real concurrent traffic.
[multiple-choice] What is the key structural difference between Apache + mod_php and Nginx + PHP-FPM? A. mod_php cannot serve static files at all B. mod_php embeds PHP directly inside every Apache worker; Nginx never runs PHP itself and instead proxies .php requests to a separate FPM process pool over FastCGI C. Nginx + PHP-FPM cannot use HTTPS D. There is no real difference -- they use the exact same process model
B. mod_php embeds PHP directly inside every Apache worker; Nginx never runs PHP itself and instead proxies .php requests to a separate FPM process pool over FastCGI — mod_php makes every Apache worker itself PHP-capable, carrying PHP's memory footprint even for non-PHP requests, while Nginx stays a lean proxy/static server and hands PHP requests off entirely to a separately managed FPM pool.
[multiple-choice] What is the main architectural difference RoadRunner and FrankenPHP introduce compared to PHP-FPM's default model? A. They eliminate PHP entirely and run everything in Go B. They can keep PHP worker processes -- and their bootstrapped application state -- alive across many requests instead of tearing down and rebuilding state on every request C. They only work with Apache, never Nginx D. They remove the need for a web server entirely, even for static files
B. They can keep PHP worker processes -- and their bootstrapped application state -- alive across many requests instead of tearing down and rebuilding state on every request — Their signature feature is worker mode: a PHP process can boot your application once and reuse that state (DI container, config, etc.) across many subsequent requests, cutting per-request bootstrap cost at the price of needing to manage state that now persists between requests.
[true-false] The PHP built-in development server (php -S) is suitable for production web traffic as long as traffic is low. A. True B. False
B. False — False -- it's explicitly documented as a development-only tool: it's single-threaded and has no process management, so it should never be used to serve production traffic regardless of volume.
[multiple-choice] What feature do Caddy and FrankenPHP both highlight over Nginx and Apache? A. They are the only options that can run PHP code at all B. Automatic HTTPS certificate provisioning and renewal with minimal manual configuration C. They eliminate the need for PHP-FPM in every case D. They only work for static websites with no dynamic content
B. Automatic HTTPS certificate provisioning and renewal with minimal manual configuration — Both Caddy and FrankenPHP (built on Caddy) provision and renew Let's Encrypt TLS certificates automatically out of the box, a manual/tooling-dependent step (e.g. via Certbot) on Nginx and Apache.
A team runs a low-traffic internal admin tool (a handful of requests per hour) and a separate high-traffic public API (thousands of requests per second) on the same infrastructure budget. Recommend an architecture (web server + PHP execution model) for each, and justify the choice using the tradeoffs from this lesson.
Internal admin tool: Nginx (or Caddy) + PHP-FPM with pm = ondemand -- traffic is sparse enough that near-zero idle memory matters far more than the occasional cold-start fork latency. Public API: Nginx + PHP-FPM with pm = static sized to measured load, or RoadRunner/FrankenPHP worker mode if the framework supports it and the team has audited for state leaks -- at thousands of req/s, eliminating per-request bootstrap cost meaningfully raises throughput, and static/dynamic FPM sizing plus more app-server instances remains the safer default if worker-mode isn't confidently ready.
Think about idle memory cost vs cold-start latency for the low-traffic tool, and per-request boot cost vs raw throughput for the high-traffic API.