What Lands in $_SERVER, $_POST, and $_FILES When the Request Arrives
You are wiring up an endpoint that accepts an avatar upload plus a display name, and the first debugging session is always the same: $_POST['name'] is null, $_FILES['avatar'] is missing, or the API key you read from $_SERVER['HTTP_API_KEY'] comes back empty. Nothing is broken — you are reading the wrong superglobal. PHP hands you three different views of one HTTP request, and each view holds a disjoint slice of it.
$_SERVER mixes request facts with server configuration. The request entries you will actually reach for look like this:
$_SERVER['REQUEST_METHOD'] // 'POST'
$_SERVER['REQUEST_URI'] // '/upload?format=compact' — path AND query, raw
$_SERVER['QUERY_STRING'] // 'format=compact'
$_SERVER['CONTENT_TYPE'] // 'multipart/form-data; boundary=----xyz'
$_SERVER['CONTENT_LENGTH'] // '82431'
$_SERVER['REMOTE_ADDR'] // '203.0.113.9'
$_SERVER['HTTP_USER_AGENT'] // 'Mozilla/5.0 ...'
$_SERVER['HTTP_X_REQUEST_ID'] // 'req_8f1c' from X-Request-Id
Incoming headers arrive transformed: the dash becomes an underscore and the name is uppercased, so a client sending X-Request-Id lands in HTTP_X_REQUEST_ID. Two entries are exceptions and keep no HTTP_ prefix: CONTENT_TYPE and CONTENT_LENGTH. That transformation is the whole answer to the classic mystery of why $_SERVER['HTTP_API_KEY'] is empty when the client swore it sent the header — check the spelling before you check the client.
$_POST holds only a parsed application/x-www-form-urlencoded or multipart/form-data body. A JSON body never populates it; that is a body you read yourself from php://input. $_GET holds the parsed query string. The two never overlap, and REQUEST_URI carries the raw string that PHP parsed to produce them.
$_FILES is the strange one. For a single input it is a set of parallel arrays keyed by the field name, not a nested structure:
$_FILES['avatar'] = [
'name' => 'me.png', // filename the browser supplied
'type' => 'image/png', // claimed by the client — never trust it
'tmp_name' => '/tmp/phpYk3sT', // deleted when the request finishes
'error' => 0, // an UPLOAD_ERR_* constant
'size' => 184320,
];
tmp_name only exists while the script runs, so is_uploaded_file() and move_uploaded_file() are how you promote it into permanent storage. The error field is an integer from a fixed set, and every branch of it means something different to the user:
| Constant | Value | Meaning |
|---|---|---|
UPLOAD_ERR_OK |
0 | The file arrived intact |
UPLOAD_ERR_INI_SIZE |
1 | Exceeded upload_max_filesize |
UPLOAD_ERR_FORM_SIZE |
2 | Exceeded the form's MAX_FILE_SIZE |
UPLOAD_ERR_PARTIAL |
3 | Transfer stopped midway |
UPLOAD_ERR_NO_FILE |
4 | Input submitted with no file |
UPLOAD_ERR_NO_TMP_DIR |
6 | Server temp directory missing |
UPLOAD_ERR_CANT_WRITE |
7 | Disk write failed |
UPLOAD_ERR_EXTENSION |
8 | A PHP extension halted it |
Notice the gap at 5 — the numbering is historical, and UPLOAD_ERR_NO_TMP_DIR is 6, not 5. Interviewers occasionally use that gap as a shibboleth to see whether you have actually read the constants or just copied an error !== 0 check.
Guarding the request becomes a short, predictable habit:
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Allow: POST');
exit;
}
$f = $_FILES['avatar'] ?? null;
if ($f === null || $f['error'] === UPLOAD_ERR_NO_FILE) {
http_response_code(422); // form submitted without a file
exit;
}
if ($f['error'] !== UPLOAD_ERR_OK) {
http_response_code(422); // INI_SIZE, PARTIAL, CANT_WRITE land here
exit;
}
move_uploaded_file($f['tmp_name'], '/var/www/uploads/' . basename($f['name']));
Everything above the guards is request reading; everything below is your own rules. Keeping that boundary sharp is what Security Headers and Cookie Flags hardened from the response side of the same exchange. The response side has its own ordering rule, and the first topic after this one — Choosing HTTP Status Codes — builds directly on knowing which superglobal holds what.
Why 'Headers Already Sent' Kills Your Redirect, and How Ordering Fixes It
You add header('Location: /login'); to a script that already printed an HTML header, and the browser renders the page instead of redirecting. error_log holds one line: Cannot modify header information - headers already sent by (this script) at line 12. The line you are staring at is innocent — the output happened earlier.
PHP holds the status line and headers until the first byte of body output. At that moment they leave together, and HTTP gives you no way to amend them afterwards: a status line cannot be re-sent, and a Location header cannot be appended to a response that has already started. header() succeeds only while the response is un-started, and http_response_code() lives under the same rule — which is why Reading Requests with Superglobals insists you read the request, decide, and only then render.
The first byte is easy to miss. The usual sources, in the order you will meet them:
- whitespace or a blank line before
<?phpin the entry script - a UTF-8 byte-order mark (
EF BB BF) at the top of an included file - a closing
?>tag followed by a trailing newline inside a class file - a
var_dump()or a notice printed whiledisplay_errors=On
None of them look like output. All of them count.
<?php
// BROKEN — layout prints first, then the header call fails:
include 'layout-top.php'; // 4 KB of HTML
header('Location: /welcome'); // warning, call dropped, 200 returned
// CORRECT — decide first, emit once, stop:
http_response_code(302);
header('Location: /welcome', true, 302);
header('Set-Cookie: flash=1; Path=/; HttpOnly; SameSite=Lax', true);
exit;
The exit is not decoration. PHP does not treat Location as terminal — the rest of the script keeps running and its output becomes the response body. Browsers usually ignore a 302 body, but curl, mobile HTTP stacks, and anything reading Content-Length will see it, and appending a body to a 204 or 304 is a protocol violation.
The trap that produces intermittent failures is output_buffering. With the buffer enabled — 4096 bytes is a common php.ini-production value — early output sits in memory, headers are still modifiable, and no warning appears until the buffer fills or the request ends. A script that prints a line and then calls header() passes under buffering and breaks the day the payload grows past 4 KB, or on a server configured with output_buffering=0. Set headers before any output, always, and treat buffering as an accident rather than a guarantee.
Two functions make the state visible when you are debugging:
if (headers_sent($file, $line)) {
// $file and $line name the script/line that produced the first byte
error_log("headers already sent by $file:$line");
}
foreach (headers_list() as $h) {
error_log($h); // what is queued so far, e.g. "Content-Type: application/json"
}
setcookie() obeys the same ordering — called after output it returns false and the cookie silently never reaches the client, which surfaces weeks later as "sessions randomly don't stick". Checking that return value in development converts a mystery into a line number.
For API code the discipline collapses into one helper: a respond(int $status, array $payload) function that calls http_response_code(), sets every header, echoes JSON once, and returns. Once every response flows through it, "headers already sent" has exactly one place where it is impossible to produce, and that helper is where Choosing HTTP Status Codes plugs in its status and Location rules.
Which Superglobal Values Are Attacker-Controlled and What Guards They Need
PHP gives every superglobal value the same treatment — mostly strings, no labels saying where they came from — and that is exactly how you get burned. $_SERVER['HTTP_X_FORWARDED_FOR'] looks as authoritative as $_SERVER['REMOTE_ADDR']. Only one of them is written by infrastructure; the other arrives in the request headers with the client's wording on it.
Start with the header-derived entries. Because PHP mirrors every incoming header into $_SERVER as HTTP_*, the client can write arbitrary keys into your server array:
Client sends: X-Forwarded-For: 9.9.9.9 Host: evil.example
$_SERVER['HTTP_X_FORWARDED_FOR'] = '9.9.9.9' // whatever the client typed
$_SERVER['HTTP_HOST'] = 'evil.example'
$_SERVER['REMOTE_ADDR'] = '203.0.113.9' // the socket peer — real
X-Forwarded-For is trustworthy only when your reverse proxy overwrites it and the immediate peer is on your trusted-proxy list. Straight from the internet, that header is a costume:
$trusted = ['10.0.0.4', '10.0.0.5']; // your load balancers
$peer = $_SERVER['REMOTE_ADDR'];
$ip = in_array($peer, $trusted, true)
? (explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '')[0] ?? $peer)
: $peer;
HTTP_HOST deserves its own warning because of the classic password-reset bug. Code that builds an email link as 'https://' . $_SERVER['HTTP_HOST'] . '/reset?token=' . $token mails a URL whose host the attacker chose; the victim clicks it, the token lands on the attacker's server. The fix is boring: build absolute URLs from configuration, and if you ever must read Host, check it against an allowlist of your own domains and reject everything else.
REQUEST_URI is raw path plus query, straight from the client. Echo it unescaped into a page and you have reflected XSS; use it unvalidated as a redirect target and you have an open redirect. Route it through your dispatcher and render it through output escaping — the response hardening side is covered in Security Headers and Cookie Flags.
$_COOKIE is equally attacker-authored: any client can send session=abc or role=admin. Cookies are safe only when you treat each value as an untrusted reference — the session id is looked up and validated server-side, and authorization comes from the loaded record, never from a field you parsed out of the cookie yourself.
$_GET inherits the same lens. A ?next=/dashboard used as a redirect target has to be validated against a whitelist of local paths, and the obvious check is wrong: str_starts_with($value, '/') admits //evil.example — a protocol-relative URL the browser happily leaves your origin with. And ?id=42 is cast to (int) before it reaches SQL, not because the string is dangerous by itself but because a digit-only value cannot carry a payload at all. A value's origin — query, header, cookie, or body — tells you which validation it owes, and no origin defaults to safe.
Body limits also fail quietly. When a form exceeds post_max_size (8 MB by default), PHP empties both $_POST and $_FILES — no error, no exception. The support ticket reads "the upload works for small photos and every field goes missing for big ones", and $_SERVER['CONTENT_LENGTH'] still tells you how large the request really was:
$claimed = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0);
if ($claimed > 8 * 1024 * 1024) {
http_response_code(413); // Payload Too Large
exit;
}
For JSON bodies, php://input gives you the raw string — it is documented as unavailable for multipart/form-data, so file uploads still arrive through $_FILES. $_POST meanwhile stays empty for JSON, because PHP only parses urlencoded and multipart bodies into it.
The rules that fall out of all five cases are short: treat header-derived values as attacker input unless your proxy set them; whitelist Host; cast ids to (int) before they touch a query; escape every request value on output; and check size limits before parsing. Invalid input should end the request with a status that says so — the subject of Choosing HTTP Status Codes.
Quiz
1. A form posts name=Ada as application/x-www-form-urlencoded, then the same form is switched to send Content-Type: application/json. What changes in PHP by default?
2. An upload larger than the server's upload_max_filesize arrives. Which constant appears in $_FILES['avatar']['error'], and what does it mean?
3. Your redirect logs 'Cannot modify header information - headers already sent by (this script) at line 12', and the script produced output back at line 3. What is the correct fix?
4. Which $_SERVER entry correctly identifies the caller's IP when the client connects directly to PHP (no proxy)?
Flashcards
Question
Which superglobal holds the raw path-plus-query of the current request, and which holds its parsed query string?
Click to reveal answer
Answer
$_SERVER['REQUEST_URI'] carries the path and query exactly as sent (e.g. /upload?format=compact) and is the input to routing; $_GET holds PHP's parsed, decoded view of the query only. They never overlap — REQUEST_URI is raw, $_GET is interpreted.
Question
How does an incoming header appear in $_SERVER, and which two headers are the exceptions?
Click to reveal answer
Answer
Headers are uppercased with dashes converted to underscores and an HTTP_ prefix: X-Request-Id becomes $_SERVER['HTTP_X_REQUEST_ID']. The exceptions are Content-Type and Content-Length, which appear as CONTENT_TYPE and CONTENT_LENGTH with no HTTP_ prefix — the reason HTTP_API_KEY style lookups silently fail.
Question
What does $_FILES['avatar'] contain for a single-file input, and which field must be checked first?
Click to reveal answer
Answer
Five parallel keys: name (client filename), type (client-claimed, untrusted), tmp_name (deleted when the request ends), error (UPLOAD_ERR_* code), and size. Check error first — only UPLOAD_ERR_OK (0) means success — then promote tmp_name with is_uploaded_file()/move_uploaded_file() before the script exits.
Question
Why must header() and http_response_code() run before any output, and how do you find the culprit?
Click to reveal answer
Answer
The status line and headers leave with the first body byte and HTTP offers no way to amend them, so later calls raise 'headers already sent'. headers_sent($file, $line) names the script and line that produced the first byte; usual culprits are whitespace before <?php, a UTF-8 BOM, a closing ?> plus newline, or a stray echo/var_dump.
Question
Which $_SERVER entries are attacker-controlled, and what is the guard for each?
Click to reveal answer
Answer
Every HTTP_* entry is client-sent: trust X-Forwarded-For only when REMOTE_ADDR is a known proxy that overwrites it, validate HTTP_HOST against an allowlist before it appears in a link, and escape REQUEST_URI on output. $_COOKIE, $_GET, and $_POST are client-authored too — whitelist and cast. When a body exceeds post_max_size, $_POST and $_FILES are silently emptied while CONTENT_LENGTH still reports the size.
Revision Notes
Key Takeaways
- 1. $_SERVER carries request metadata (method, raw URI, content type, HTTP_* headers), $_GET/$_POST carry parsed query and form bodies, and $_FILES carries five parallel upload keys — JSON populates none of them
- 2. Headers arrive uppercased with dashes as underscores, and CONTENT_TYPE/CONTENT_LENGTH are the only entries without the HTTP_ prefix
- 3. header(), http_response_code(), and setcookie() must precede the first body byte; output_buffering hides the mistake until the buffer fills, so structure scripts as decide-then-emit
- 4. Check $_FILES['error'] against UPLOAD_ERR_* (0 OK, 1 ini size, 4 no file, 6 no tmp dir, 7 write fail) and move tmp_name before the request ends
- 5. Only REMOTE_ADDR is infrastructure-provided; HTTP_* values, cookies, and body fields are client-authored and each needs its own allowlist, whitelist, or cast
Interview Tips
- • "Where do you read the request method and path?" — $_SERVER['REQUEST_METHOD'] plus PATH_INFO, with the REQUEST_URI fallback for servers that leave PATH_INFO unset; naming both shows you have routed on more than one server
- • "Explain the 'headers already sent' error" — first body byte ends header mutation, list three real culprits (UTF-8 BOM, whitespace before <?php, closing ?> newline), then add that output_buffering masks it locally; the fix is ordering, not buffering
- • "How do you handle a file upload safely?" — walk the UPLOAD_ERR_* codes, reject anything but UPLOAD_ERR_OK, verify the real type with finfo rather than the client-claimed $_FILES['type'], then move_uploaded_file()
- • "What do you trust from $_SERVER?" — REMOTE_ADDR on direct connections and nothing else by default: X-Forwarded-For only behind a trusted proxy that overwrites it, HTTP_HOST only against an allowlist before it enters a link
Cheat Sheet
Reading Requests with Superglobals
Which superglobal holds what:
| Need | Source | Example |
|---|---|---|
| Method | $_SERVER['REQUEST_METHOD'] |
'POST' |
| Raw path + query | $_SERVER['REQUEST_URI'] |
'/upload?format=compact' |
| Parsed query | $_GET |
['format' => 'compact'] |
| Form fields (urlencoded/multipart only) | $_POST |
['name' => 'Ada'] |
| Raw JSON body | file_get_contents('php://input') |
'{"title":"Hi"}' |
| Upload metadata | $_FILES['avatar'] |
parallel name/type/tmp_name/error/size |
| Client address | $_SERVER['REMOTE_ADDR'] |
socket peer — trusted on direct connections |
Headers in $_SERVER: uppercased, dashes → underscores, HTTP_ prefix — X-Request-Id → HTTP_X_REQUEST_ID.
Exceptions with no prefix: CONTENT_TYPE, CONTENT_LENGTH.
Upload guard:
$f = $_FILES['avatar'] ?? null;
if ($f === null || $f['error'] !== UPLOAD_ERR_OK) { // 0 = OK
http_response_code(422); // 1 ini, 2 form, 3 partial,
exit; // 4 no file, 6 no tmp dir,
} // 7 write fail, 8 extension
move_uploaded_file($f['tmp_name'], $dest);
Response ordering rule: http_response_code() / header() / setcookie() → before the first body byte → echo once → exit.
if (headers_sent($file, $line)) { /* first byte came from $file:$line */ }
foreach (headers_list() as $h) { /* queued headers */ }
Never trust as-is: HTTP_* (client-sent), HTTP_HOST (allowlist before links), REQUEST_URI (escape on output), $_COOKIE (reference, not authority), body fields (whitelist + (int) cast). Exceeding post_max_size silently empties $_POST and $_FILES — check CONTENT_LENGTH, answer 413.