PHP in 2026
The Request Lifecycle and Where Your Code Runs
What happens between a request arriving and your file running, what the share-nothing model costs you, and why a static property resets on every request.
A page renders the header, half the table, and then stops. The browser shows a truncated layout with no error on it, and refreshing gives you the same truncated page or, just often enough to be confusing, a complete one. Nothing was cached and nothing is flaky in the network sense. What you are looking at is a script that died after it had already sent part of the response.
The other half of the same confusion is state. You increment a counter on every request, and it prints 1 forever. You put a value in a static property and the next request cannot see it. Both behaviours come from the same fact about PHP, and that fact deserves a proper look before you write anything that touches a database or a session.
Each request gets a fresh interpreter, not a shared one
The model is called share-nothing, and it sounds more abstract than it is. For a classic PHP deployment, the sequence for one HTTP request goes like this. The web server accepts the connection and decides the request is for PHP. It hands the request to a PHP process that is already running and waiting: with PHP-FPM, a pool of worker processes; with the built-in server, the single process you started. That worker initialises a fresh request environment, compiles or loads the compiled script, runs it from the first line to the last, and then tears the whole environment down. Variables, objects, constants you defined, functions you declared, output buffers that were still open, all of it is released. The worker returns to the pool and waits for the next request with an empty slate.
This is why PHP had a reputation for being stateless at a time when the Java alternative was a long-lived object graph. The reputation is accurate about the request environment and misleading about the process, which does stay alive. OPcache keeps the compiled bytecode in shared memory between requests, so the process is not starting from nothing. Your code, though, starts from nothing every time.
Two things follow immediately. A value you compute and hold in a variable exists for the duration of one request and no longer. And any state that has to survive a request boundary must be stored somewhere outside the process: a file, a database, a session, a shared cache. There is no third option, and code that assumes otherwise looks correct until the second user arrives.
Superglobals are the request, already parsed
PHP fills in a handful of global arrays before your first line runs. They are called
superglobals because they are available in every scope without a global
statement, which is convenient and also the reason people use them carelessly.
| Array | What it contains | The detail that bites |
|---|---|---|
$_GET |
Parameters parsed from the query string. | Values are strings or arrays of strings, never integers. |
$_POST |
Fields from a form-encoded or multipart request body. | Empty for a JSON body, and empty for any request that is not POST. |
$_SERVER |
Request metadata the SAPI provides: method, path, headers, addresses. | Which keys exist depends on the SAPI and the web server in front of it. |
$_FILES |
Uploaded files, one entry per field, each with a temporary path. | The temporary file is deleted when the request ends. |
Nothing here is magic. The SAPI parses the request line, the headers and the body,
and populates the arrays before handing control to your script. That means the arrays
are a snapshot of one specific request and are rebuilt, empty, for the next one. A
function that reads $_GET is reading a global that happens to be in scope;
passing the value in as an argument is usually the difference between code you can test
and code you cannot.
The type column in that table causes real bugs. Query strings and form
bodies are text. ?page=2 gives you the string "2", not the
integer 2. Comparison with === against 2 fails,
arithmetic works because PHP converts, and a strict-types file does not convert for you
at the boundary. Cast, or validate and then cast.
A JSON body is not in $_POST
Every developer hits this once. A client sends Content-Type:
application/json with a body of {"email":"ana@example.com"}, the
handler checks $_POST['email'], and it is missing. PHP populates
$_POST for two content types only: application/x-www-form-urlencoded
and multipart/form-data. A JSON body matches neither, so the array stays
empty and the data sits in the raw request body.
Read it from the php://input stream and decode it yourself:
<?php
declare(strict_types=1);
function jsonBody(): array
{
$raw = file_get_contents('php://input');
if ($raw === '' || $raw === false) {
return [];
}
$decoded = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
return is_array($decoded) ? $decoded : [];
}
$payload = jsonBody();
$_POST['email'] ??= $payload['email'] ?? null;
var_dump($payload);
Two details in that function matter more than they look. The body may legitimately be empty, so
reading it before decoding avoids an error on a request with no body.
json_decode() returns null for the JSON literal
null and throws for malformed input once you pass
JSON_THROW_ON_ERROR, which is the behaviour you want: a malformed body is a
client error, and turning it into an exception is how it reaches your error handling
instead of silently becoming an empty array. In a real handler you catch that exception
and answer with a 400.
One caveat about portability. The php://input stream is not available
with enable_post_data_reading=Off, and under some FastCGI configurations a
non-seekable body can only be read once. Reading it twice works on the setups I have
tested, and code that caches the decoded body in a variable does not have to care.
Output leaves as it is produced
PHP does not build the whole response and send it at the end. It sends bytes as your
script echoes them, subject to buffering. The default configuration buffers a few
kilobytes before writing to the client, and you can add your own layer with
ob_start():
<?php
ob_start();
echo "this is held in a buffer\n";
$held = ob_get_contents();
ob_end_flush();
echo "buffer held ", strlen($held), " bytes\n";
A buffer changes when bytes leave, not whether. Anything still buffered when the
script reaches its end, or when it dies, is flushed on the way out, so a fatal error
halfway through a page produces exactly what the opening paragraph described: the part
that had already been produced, followed by the error text if
display_errors is on, and nothing if it is off. The response has already
committed to a status code and, for practical purposes, to a set of headers. You cannot
rewrite a 200 into a 500 at that point.
That last sentence is the reason frameworks render a template to a string and echo it
in one place at the end, and the reason an API should build its payload and then send it.
It is also why a trailing ? in a configuration file can turn a clean error
into a blank page: whitespace sent before header() is called means the
header call fails with a warning, and the visitor sees nothing useful.
Buffering is not an undo button
ob_start() lets you discard what you have produced so far and send
something else instead. Once the buffer has been flushed, whether by size or at
shutdown, the bytes are gone. Treat any echo before your last validation as final.
A long-running process breaks the model on purpose
Everything above describes the classic SAPI: PHP-FPM, Apache's module, the built-in server. Newer runtimes such as RoadRunner, Swoole and FrankenPHP keep a PHP process alive and let it handle many requests, which changes the rules. State that used to be rebuilt per request now persists across requests, and that is either the point of the runtime or the source of your worst bug.
Here is the honest test. One file, served by the built-in server:
<?php
declare(strict_types=1);
final class VisitCounter
{
public static int $requests = 0;
public function __construct(public readonly string $id = '')
{
}
}
VisitCounter::$requests++;
echo VisitCounter::$requests, PHP_EOL;
Request that file three times and you get 1, 1,
1. Not because the process restarted, but because each request built a new
class table and a new set of statics, ran the file, and threw them away. Run the same
class from a loop inside one CLI script and it counts 1, 2, 3, because there the process
is the unit of work. The static belongs to the request, not to the class in the abstract.
That single example carries the whole contrast, and it tells you where to put state. A counter that must survive requests goes in a database, a cache, or a file with locking. A cache that is merely nice to keep goes in APCu or Redis, both of which are shared memory outside the request. A configuration value can be recomputed, and a request-scoped value should never be trusted to outlive its request. In a long-running runtime, invert the assumption: assume everything persists until you prove it does not, and reset per-request state explicitly between requests.
Write one file, public/inspect.php, and serve it with
php -S localhost:8000 -t public.
The file should print a small table of facts about the current request: the
request method, the path without its query string, the value of
$_GET['q'] if present, whether $_POST is empty, and how
many of the keys in $_SERVER start with HTTP_.
Then exercise it three ways with curl: a GET with a query string, a
POST with Content-Type: application/json, and a POST with
Content-Type: application/x-www-form-urlencoded. Record what
$_POST holds in each of the two POST cases, and write one sentence
explaining the difference.
Finally, add a static counter to the top of the file and request the page five times. State what it prints and what that tells you about the lifetime of a static property under the built-in server.
Show the solution and the reasoning
<?php
declare(strict_types=1);
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?: '/';
$headerCount = count(array_filter(
array_keys($_SERVER),
static fn (string $key): bool => str_starts_with($key, 'HTTP_'),
));
$query = $_GET['q'] ?? '(absent)';
$postState = $_POST === [] ? 'empty' : 'populated';
printf("method %s\n", $_SERVER['REQUEST_METHOD']);
printf("path %s\n", $path);
printf("q %s\n", $query);
printf("post %s\n", $postState);
printf("HTTP_* keys %d\n", $headerCount);
echo str_repeat('-', 20), PHP_EOL;
echo 'statics are per request', PHP_EOL;
The ?: '/' on the path handles the case where
parse_url() returns null or an empty string, which is not
common in practice and is cheap to guard against. Filtering
$_SERVER for HTTP_ keys is a rough count of request headers,
and the reason it is rough matters: PHP exposes most headers with that
prefix, but not Content-Type and Content-Length, which live
in CONTENT_TYPE and CONTENT_LENGTH. A handler that rewrites
$_SERVER into a header map should special-case those two. On a server
behind a proxy, the client headers may also be rewritten into
HTTP_X_FORWARDED_* values that you should treat as untrusted.
For the two POST cases, the JSON request leaves $_POST empty while the
form request populates it. The reason is content type, not method: PHP parses the body
into $_POST only for application/x-www-form-urlencoded and
multipart/form-data. Reading JSON means reading
php://input and decoding it, and any handler that accepts both should
decide which one it is looking at from CONTENT_TYPE before it reads
anything.
The counter prints 1 every time. You are watching the share-nothing model from
from the outside, and it also explains the half-rendered page from earlier in the
lesson: nothing about the request survives, including the assumption that your script
finished. If you need a counter that means something, store it outside the process, and
accept that reading and writing it concurrently is a different problem from the one
this lesson solves.
Progress
Mark this lesson done and it stays marked in this browser.