The Request Lifecycle in PHP
Date: 2026-09-27
Classic PHP is shared-nothing: every request starts from an empty interpreter and ends by throwing everything away. That one fact explains PHP’s robustness, its per-request bootstrap cost, why it needs an external connection pooler and a cache, and exactly which bugs appear when you switch to a long-running worker.
The PHP request lifecycle is the sequence a request goes through — a process picks it up, the script’s state is built from nothing, a response is produced, and all request state is destroyed. Shared-nothing is the property that follows: no request can see anything another request left in memory.
Who does what
browser
│
▼
web server (nginx) static files served directly; .php forwarded
│ FastCGI
▼
PHP-FPM master manages a pool of worker processes
│
├─ worker 1 ── one request at a time ──┐
├─ worker 2 │
└─ worker N │
▼
┌────────────────────────────────────────────┐
│ per request │
│ 1 init superglobals built: │
│ $_GET $_POST $_COOKIE $_SERVER│
│ 2 compile source → opcodes │
│ (skipped if OPcache has them) │
│ 3 execute your code; framework boots │
│ 4 output headers + body to FastCGI │
│ 5 shutdown every variable, object, │
│ connection freed │
└────────────────────────────────────────────┘
worker survives, state doesn't → next request
PHP-FPM (FastCGI Process Manager) is the usual runtime: a master process managing a pool of workers, each handling exactly one request at a time. FastCGI is the protocol the web server uses to hand it requests. The worker process is reused; the interpreter state is wiped between requests.
Concurrency is process count. Twenty workers means twenty simultaneous requests; the twenty-first waits in the queue. There’s no event loop, so a request blocked on a slow API holds a whole worker for the duration — vs JS: Node would serve other requests during that wait — The Event Loop · Async Models.
What survives, and what doesn’t
DIES WITH THE REQUEST SURVIVES
local and global variables OPcache — compiled opcodes, shared memory
static properties, singletons APCu — a key-value cache in shared memory,
database connections (unless per server, per FPM pool
persistent) anything external: Redis, the database,
open files, sessions in memory the filesystem
the framework's booted container the worker process itself
OPcache is why the model is affordable. Without it every request re-parses every file; with it, compiled code is read from shared memory. It’s the first thing to confirm is on in production. Preloading (PHP 7.4+) goes further, loading chosen classes into memory once at server start.
Everything else must be rebuilt. A framework like Laravel or Symfony constructs its service container, reads config and registers routes on every request — the cost that framework config and route caching exist to cut.
Consequences
Good ones:
- Leaks are self-healing. A memory leak lasts one request.
pm.max_requestsrecycles a worker after N requests anyway, as insurance against leaks in extensions - Crashes are contained. A fatal error kills one response, not the server
- No data races between requests. Nothing is shared in memory, so there’s nothing to lock — Race Conditions still happen, but in the database, not the process
Costly ones:
- No in-process connection pool. Every request opens and closes its database connection; at scale, an external pooler (PgBouncer for Postgres, ProxySQL for MySQL) sits in between — Reference - PHP covers the persistent-connection option and why it’s risky
- No in-memory cache between requests. Caching means APCu or Redis — Caching Strategies
- No work after the response, natively. Sending an email means making the user wait, or a queue plus a separate worker process — Message Queues. Under FPM,
fastcgi_finish_request()flushes the response and lets the script carry on, which is a hack for small jobs, not a queue - Timeouts are per request.
max_execution_timeand the web server’s FastCGI timeout both cap a request; long imports belong in a CLI (command-line) process, which has no time limit by default
Long-running PHP: the model turned off
Worker-mode runtimes — FrankenPHP’s worker mode, RoadRunner, Swoole, with Laravel Octane as a common wrapper — boot the application once and keep it in memory, feeding it requests in a loop. The bootstrap cost disappears. So does shared-nothing.
final class CurrentCustomer
{
private static ?Customer $customer = null;
public static function set(Customer $c): void { self::$customer = $c; }
public static function get(): ?Customer { return self::$customer; }
}
// Request 1, logged in as ana: CurrentCustomer::set($ana);
// Request 2, anonymous visitor: CurrentCustomer::get()
// → FPM: null. The static died with request 1
// → worker mode: $ana. The static survived — ben now sees ana's basketCode written for FPM was allowed to be careless about statics and singletons, because shutdown cleaned up after it. In a worker that same code leaks memory steadily and, worse, leaks one user’s data into another’s response. Frameworks reset their own state between requests (Symfony via ResetInterface, Octane via its own resets); your services and any package holding static state are your problem.
Before adopting a worker runtime, audit statics and singletons, set a max-requests recycle as a safety net, and load-test with interleaved logged-in users rather than one — Memory and Long Sessions is the browser-side version of the same leak shape. [CHECK: current worker-mode support and reset behaviour per framework and runtime — this area is moving.]
vs Node, in one table
| PHP-FPM | Node | |
|---|---|---|
| Process lifetime | Per request (state), reused (process) | One process, whole uptime |
| Concurrency | One request per worker | Many per process, via the event loop |
| Blocking I/O | Normal and fine | Stalls every other request |
| Module-level state | Gone after each request | Persists, shared by all requests |
| Memory leak | Lasts one request | Grows until restart |
| Connection pool | External | In-process |