The PHP Request Model and Servers

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against PHP 8.5 (8.5.11), checked on 2026-10-05; every output on it was captured from the official php:8.5-fpm and php:8.5-apache images, Nginx 1.31 and FrankenPHP 1.13 (Caddy 2.11). It is about plain PHP; Laravel adds nothing to this layer except Octane, covered on Octane and Performance. How a script is compiled and executed is on the PHP Reference’s Getting Started and OPcache, JIT and Performance pages.

The shared-nothing model

PHP’s engine has two lifecycles. When a PHP process starts, every extension runs its module startup (MINIT): it registers functions, classes, constants and INI settings, once. Then, for each request, the engine runs request startup (RINIT), executes the script, and runs request shutdown (RSHUTDOWN), which frees every variable, object and resource the request created. Only the module-level state (and OPcache’s shared memory) survives from one request to the next; the application’s own state never does.

This shared-nothing model is the defining property of PHP on the web:

  • each request starts from a clean slate, so a bug or a memory leak cannot outlive it;

  • any process, on any server, can handle any request, so scaling out means adding processes and servers behind a load balancer (sessions and caches must then live in shared storage such as Redis or the database);

  • the cost is that the application boots on every request: autoloading, reading configuration, registering routes and services. OPcache removes the compile step; worker mode (below) removes the boot.

The script below makes the model visible: a static variable counts requests, and under PHP-FPM it is 1 every time, whichever worker answers.

<?php
declare(strict_types=1);

// Shared-nothing: this static lives only for one request under PHP-FPM
static $requests = 0;
$requests++;

header('Content-Type: text/plain');
printf("pid %d, requests seen by this script: %d, SAPI %s\n", getmypid(), $requests, PHP_SAPI);
pid 7, requests seen by this script: 1, SAPI fpm-fcgi
pid 8, requests seen by this script: 1, SAPI fpm-fcgi
pid 7, requests seen by this script: 1, SAPI fpm-fcgi
pid 8, requests seen by this script: 1, SAPI fpm-fcgi

SAPIs: how PHP is connected to a server

A SAPI (Server API) is the layer between the engine and whatever drives it. PHP_SAPI (or php_sapi_name()) names the one in use:

SAPI PHP_SAPI How it works

PHP-FPM

fpm-fcgi

A FastCGI process manager: a master process supervises pools of worker processes; the web server forwards requests over FastCGI (TCP or a Unix socket). The standard production setup.

Apache module (mod_php)

apache2handler

PHP runs inside each Apache child process (prefork MPM only, because PHP extensions are not all thread-safe). Simple, but every Apache process carries PHP, even for static files.

CGI and FastCGI (php-cgi)

cgi-fcgi

The original model: one process per request (CGI), or long-lived processes without FPM’s management (FastCGI). Superseded by PHP-FPM.

Built-in server (php -S)

cli-server

A single-process development server. Never for production: it handles one request at a time.

FrankenPHP

frankenphp

PHP embedded in the Caddy web server, as threads of one process, with an optional worker mode.

CLI

cli

Scripts, Artisan, queue workers and cron jobs (see the PHP Reference’s Command-Line PHP).

Measured on the official images: the same script reports fpm-fcgi under PHP-FPM, apache2handler (with mpm_prefork_module and php_module loaded) under php:8.5-apache, cli-server under php -S, and frankenphp under FrankenPHP.

PHP-FPM

PHP-FPM runs one master process and one or more pools. Each pool has its own user, listen address, process manager settings, php.ini overrides and logs, so several applications on one server can be isolated from each other. A worker handles one request at a time; the number of workers is therefore the number of requests the pool can serve concurrently.

; /usr/local/etc/php-fpm.d/www.conf (Debian: /etc/php/8.5/fpm/pool.d/www.conf)
[www]
user = www-data
group = www-data
listen = 9000                     ; or a socket: /run/php/php8.5-fpm.sock

pm = dynamic                      ; static | dynamic | ondemand
pm.max_children = 10              ; hard limit on workers: size it from memory
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.max_requests = 500             ; recycle a worker after 500 requests (contains slow leaks)

pm.status_path = /fpm-status
ping.path = /fpm-ping

request_slowlog_timeout = 1s      ; log a PHP backtrace of requests slower than this
slowlog = /proc/self/fd/2
request_terminate_timeout = 30s   ; kill a worker stuck on one request

catch_workers_output = yes        ; send the workers' stderr (error_log) to the FPM log
clear_env = no                    ; keep the environment variables (DB_HOST, ...) for getenv()

The three process managers:

  • static: exactly pm.max_children workers, always. Predictable latency and memory; the usual choice on a dedicated server or container.

  • dynamic: between pm.min_spare_servers and pm.max_spare_servers idle workers, never more than pm.max_children in total. Good for a server shared with other services.

  • ondemand: no workers until requests arrive; idle workers exit after pm.process_idle_timeout. Saves memory on rarely used pools, at the cost of a fork on the first request.

Size pm.max_children from memory, not from traffic: (memory available for PHP) / (memory per worker). The status page reports what each worker used. When all workers are busy, new requests wait in the listen queue, and FPM logs server reached pm.max_children setting; if that is frequent, either add memory and workers or make requests faster.

The status page (pm.status_path, here exposed only to the internal network through Nginx) and the slowlog are the first tools when a site is slow. The output below is real: a request to /slow.php, which sleeps for 1.5 seconds, was counted as slow and its backtrace logged:

$ curl http://bookshelf.test/fpm-status
pool:                 www
process manager:      dynamic
accepted conn:        2
listen queue:         0
max listen queue:     0
idle processes:       1
active processes:     1
total processes:      2
max children reached: 0
slow requests:        1

$ docker logs php    # the FPM log
[05-Oct-2026 18:59:16] WARNING: [pool www] child 7, script '/app/public/slow.php' (request: "GET /slow.php") executing too slow (1.271401 sec), logging
[05-Oct-2026 18:59:16]  [pool www] pid 7
script_filename = /app/public/slow.php
[0x00007f45ace14070] usleep() /app/public/slow.php:2
[05-Oct-2026 18:59:16] NOTICE: finished trace of 7

Two pitfalls showed up while capturing that output. The slowlog reads the worker’s stack with ptrace, which Docker forbids by default: without --cap-add SYS_PTRACE FPM logs failed to ptrace(ATTACH) child 7: Operation not permitted instead of the backtrace. And ?full on the status page adds a block per worker with its last request, duration, CPU and memory, which is how to find the URL that is using up the pool.

fastcgi_finish_request() sends the response to the client and lets the worker continue. It is useful for short follow-up work, but the worker stays busy (and unavailable to other requests) until the script ends; for anything substantial use a queue (see Queues and Jobs).

<?php
declare(strict_types=1);

echo "Thanks, your review was received.\n";
fastcgi_finish_request();            // the client gets its response now

sleep(2);                            // slow follow-up work the client no longer waits for
error_log('review notification sent');

Measured: the client received the response immediately (client waited 0s), and the FPM log showed NOTICE: PHP message: review notification sent two seconds later.

Nginx in front of PHP-FPM

Nginx serves static files itself and passes everything else to PHP-FPM over FastCGI. The front-controller rule try_files $uri $uri/ /index.php?$query_string sends every URL that is not a real file to public/index.php, which is how Laravel and Bookshelf Lite receive all their routes. This is the configuration the examples on this page ran with:

server {
    listen 80;
    server_name bookshelf.test;
    root /app/public;                        # only public/ is reachable from the web
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ ^/fpm-(status|ping)$ {
        allow 127.0.0.1;
        allow 172.16.0.0/12;                 # internal network only
        deny all;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $fastcgi_script_name;
        fastcgi_pass php:9000;
    }

    location ~ \.php$ {
        try_files $uri =404;                 # never pass a non-existent script to PHP
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;
        fastcgi_pass php:9000;               # or unix:/run/php/php8.5-fpm.sock
    }

    location ~ /\. {
        deny all;                            # .env, .git and other dotfiles
    }
}

The security-relevant details: the document root is public/, never the project root (so .env, vendor/ and the source are not downloadable); try_files $uri =404 stops Nginx from passing a request for /uploads/evil.jpg/x.php to PHP; and $realpath_root resolves symlinks, so atomic symlink-switch deployments pick up the new release. On Apache, the equivalent is a DocumentRoot pointing at public/ with the .htaccess rewrite rules that Laravel ships.

FrankenPHP and worker mode

FrankenPHP is an application server built on the Caddy web server, with PHP embedded: there is no FastCGI hop and no separate PHP-FPM. It serves HTTPS with automatic certificates, HTTP/2 and HTTP/3, and early hints. In its classic mode it behaves like PHP-FPM, one fresh request at a time per thread. In worker mode a PHP script boots the application once and then handles requests in a loop with frankenphp_handle_request(), which removes the cost of bootstrapping from every request.

<?php
declare(strict_types=1);

// public/index.php in worker mode. Booted once per worker: config, container, routes
$booted = date('H:i:s');
$served = 0;
$visitors = [];                                   // state that outlives a request: careful

$handler = static function () use (&$served, &$visitors, $booted): void {
    $served++;
    $visitors[] = $_GET['name'] ?? 'anonymous';   // BUG: grows across requests and users
    header('Content-Type: text/plain');
    printf("worker %d booted %s, request #%d, visitors so far: %s\n",
        getmypid(), $booted, $served, implode(', ', $visitors));
};

// Handle requests until the worker is restarted (a request limit contains slow leaks)
for ($n = 0; $n < 500 && frankenphp_handle_request($handler); $n++) {
    gc_collect_cycles();
}
# Caddyfile
{
    frankenphp {
        worker {
            file /app/public/index.php
            num 1
        }
    }
}

:80 {
    root /app/public
    php_server
}
$ for n in ada bob carol; do curl "localhost/books?name=$n"; done
worker 1 booted 18:55:51, request #1, visitors so far: ada
worker 1 booted 18:55:51, request #2, visitors so far: ada, bob
worker 1 booted 18:55:51, request #3, visitors so far: ada, bob, carol

The output is the point: carol’s request sees ada’s and bob’s data. In worker mode, anything stored outside the request handler survives: static properties, singletons in a container, arrays used as caches, an authenticated user kept in a service. Code written for the shared-nothing model can leak one user’s data into another user’s response. The rules for worker mode are therefore:

  • keep request data inside the handler and reset services between requests (Laravel Octane does this for the framework’s own services, but not for your static properties or singletons);

  • restart workers regularly (here after 500 requests) to contain memory leaks;

  • expect a fatal error to kill the worker, not just one request (FrankenPHP restarts it);

  • treat long-lived connections (database, Redis) as something that can go stale.

FrankenPHP runs PHP as threads of a single process (it needs a thread-safe PHP build), which is why getmypid() prints the same value for every request above and for the classic-mode script as well.

RoadRunner (a Go application server that runs PHP workers over pipes) and Swoole (a PHP extension with its own event loop and coroutines) offer the same booted-once model with different trade-offs; Swoole changes how I/O works and needs code written for it. Laravel supports all three through Octane.

OPcache in production

Every production PHP server should run with OPcache enabled, timestamps validation off and the cache reset on deploy (by reloading PHP-FPM or restarting FrankenPHP). The settings and the measurements are on the PHP Reference’s OPcache, JIT and Performance page. Under PHP-FPM the cache lives in the master’s shared memory, so all workers of all pools share it; restarting the master clears it.

Persistent connections and connection handling

Opening a database connection costs a round trip and authentication on every request. PDO::ATTR_PERSISTENT keeps the connection open in the worker process after the request ends and reuses it for the worker’s next request. Measured with PHP-FPM against MySQL 8.4, with two workers:

<?php
declare(strict_types=1);

$persistent = isset($_GET['persistent']);
$pdo = new PDO(
    'mysql:host=mysql;dbname=bookshelf;charset=utf8mb4',
    getenv('DB_USER') ?: 'bookshelf',
    getenv('DB_PASSWORD') ?: '',
    [PDO::ATTR_PERSISTENT => $persistent],
);
printf("pid %d, MySQL connection id %d (%s)\n", getmypid(),
    $pdo->query('SELECT CONNECTION_ID()')->fetchColumn(), $persistent ? 'persistent' : 'new');
pid 7, MySQL connection id 8 (new)
pid 8, MySQL connection id 9 (new)
pid 7, MySQL connection id 10 (new)
pid 8, MySQL connection id 11 (new)
pid 7, MySQL connection id 12 (persistent)
pid 8, MySQL connection id 13 (persistent)
pid 7, MySQL connection id 12 (persistent)
pid 8, MySQL connection id 13 (persistent)

Persistent connections are per worker, so the database must accept pm.max_children × (number of servers) connections, and state left on a connection (an open transaction, a table lock, session variables) is inherited by the next request. They help when connecting is expensive (remote databases with TLS); with a local database the gain is small. Laravel does not use them by default.

When the client disconnects, PHP stops the script at the next output by default. ignore_user_abort(true) lets it run to the end (for example to finish a payment callback), connection_aborted() tells the script the client has gone, and max_execution_time (or FPM’s request_terminate_timeout) still limits the total run time.

The figure: PHP-FPM versus worker mode

Diagram comparing the two ways PHP serves web requests. On the left, a browser sends HTTP to Nginx, which serves static files and passes PHP requests over FastCGI on TCP port 9000 or a socket to the PHP-FPM master and its pool of workers; each worker starts the request, runs index.php (booting the application, routing, running the controller, querying and rendering), sends the response and frees everything, handling one request at a time, with extra workers spawned up to pm.max_children. OPcache in the master’s shared memory holds compiled opcodes for all workers, and the database and Redis are connected per request or per worker. On the right, the browser talks HTTP to FrankenPHP, a Caddy server with PHP embedded and no FastCGI; worker threads boot the application once and then loop over frankenphp_handle_request, resetting request state and restarting after a maximum number of requests. A pitfalls box warns that statics and singletons survive between requests, data of one user can leak into the next, memory leaks accumulate and a fatal error kills the worker. OPcache is still used but bootstrapping is no longer repeated, and connections are kept open and must be health-checked.

Where older books and tutorials are outdated

  • Apache with mod_php as the default (Powers ch. 1-2, Nixon ch. 1-2, Kromann’s installation chapters), configured through httpd.conf and .htaccess, is now the exception: PHP-FPM behind Nginx, Apache with mod_proxy_fcgi, or FrankenPHP are the common production setups, and containers replace hand-configured servers.

  • XAMPP, MAMP and AMPPS bundles for development are replaced by php -S, Docker, Laravel Sail and Laravel Herd (see Laravel Getting Started).

  • "mod_php versus CGI versus FPM" (Beak ch. 6) is settled: plain CGI is obsolete, and FPM or an application server is the norm.

  • Worker-mode servers (FrankenPHP, RoadRunner, Swoole) and the stateful pitfalls they bring are missing from every book.