HTTP Requests and Responses
|
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; request_parse_body() and
http_get_last_response_headers() need PHP 8.4, and the URI extension needs 8.5. Every example ran in the
php:8.5-cli image behind the built-in server, and the outputs are the real responses (with the Date and similar
headers removed). It covers what PHP itself gives a request handler; Laravel’s Request and Response objects, which
wrap all of this, are on Requests, Input and Responses.
The request: superglobals
Before the script runs, PHP parses the request into superglobal arrays, available everywhere without global:
| Variable | Contents |
|---|---|
|
The query-string parameters (for any method, not only GET) |
|
The body fields of a request with |
|
Uploaded files of a |
|
The cookies the client sent |
|
The request line, headers as |
|
|
|
Environment variables, only if |
|
The session data, after |
<?php
declare(strict_types=1);
// /inspect: print what PHP parsed from the request
header('Content-Type: text/plain; charset=utf-8');
printf("%s %s\n", $_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
printf("query string: %s\n", $_SERVER['QUERY_STRING'] ?? '');
printf("\$_GET: %s\n", json_encode($_GET));
printf("\$_POST: %s\n", json_encode($_POST));
printf("\$_COOKIE: %s\n", json_encode($_COOKIE));
printf("\$_REQUEST: %s (request_order=%s)\n", json_encode($_REQUEST), ini_get('request_order'));
printf("Accept: %s\n", $_SERVER['HTTP_ACCEPT'] ?? '-');
$ curl -X POST 'localhost:8000/inspect?sort=title&page=2' -H 'Cookie: theme=dark' -d 'title=Kindred&page=9'
POST /inspect?sort=title&page=2
query string: sort=title&page=2
$_GET: {"sort":"title","page":"2"}
$_POST: {"title":"Kindred","page":"9"}
$_COOKIE: {"theme":"dark"}
$_REQUEST: {"sort":"title","page":"9","title":"Kindred","theme":"dark"} (request_order=)
Accept: */*
Source: PHP Manual — Superglobals, $_SERVER and request_order
The output shows why $_REQUEST is a trap. page appears in the query string and the body, and the body won
silently. Without a php.ini, request_order is empty, so variables_order (default EGPCS) applies and the
cookie is in $_REQUEST too: an attacker who can set a cookie can override a form field. With the
php.ini-production value request_order=GP, the same request gives $_REQUEST: {"page":"9"}, without the cookie.
Read from the array you mean: $_GET for the URL, $_POST for the form.
All superglobal values are strings (or arrays of strings, for tags[]=a&tags[]=b), never int or bool, and any
of them can be missing or contain anything the client chooses. Validate before use (see
Forms, Validation and Output Escaping). $_SERVER
values that come from headers (HTTP_HOST, HTTP_X_FORWARDED_FOR, PHP_SELF's path part) are client-controlled
too.
Configuration and secrets come from the environment: getenv('DB_PASSWORD') works with any variables_order
(PHP-FPM needs clear_env = no, or env[...] entries in the pool, to pass variables through).
Request bodies: JSON, PUT and PATCH
PHP fills $_POST only for form-encoded and multipart POST bodies. Everything else (JSON, XML, a raw upload) is read
from the php://input stream. A JSON API endpoint checks the Content-Type, decodes with exceptions, and answers
with the right status:
<?php
declare(strict_types=1);
// POST /api/books
if (!str_starts_with($_SERVER['CONTENT_TYPE'] ?? '', 'application/json')) {
http_response_code(415); // Unsupported Media Type
header('Content-Type: application/json');
echo json_encode(['error' => 'Send JSON']), "\n";
return;
}
try {
$data = json_decode(file_get_contents('php://input'), true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException) {
http_response_code(400); // Bad Request
header('Content-Type: application/json');
echo json_encode(['error' => 'Malformed JSON']), "\n";
return;
}
$book = ['id' => 4, 'title' => (string) ($data['title'] ?? '')]; // ... saved to the database
http_response_code(201); // Created
header('Location: /api/books/4');
header('Content-Type: application/json');
echo json_encode($book), "\n";
$ curl -i -X POST localhost:8000/api/books -H 'Content-Type: application/json' -d '{"title":"Dawn"}'
HTTP/1.1 201 Created
Location: /api/books/4
Content-Type: application/json
{"id":4,"title":"Dawn"}
$ curl -i -X POST localhost:8000/api/books -d 'title=x' # form-encoded
HTTP/1.1 415 Unsupported Media Type
$ curl -X POST localhost:8000/api/books -H 'Content-Type: application/json' -d '{bad'
{"error":"Malformed JSON"}
Source: PHP Manual — php:// wrappers and json_decode
For PUT, PATCH and DELETE requests with form or multipart bodies, PHP 8.4 added request_parse_body(). It
parses the body exactly as PHP parses a POST and returns [$post, $files], so a REST-style PUT with an upload no
longer needs a hand-written multipart parser (or the _method=PUT workaround). The body can be consumed only once:
after request_parse_body(), php://input is empty, and if php://input was read first, request_parse_body()
returns empty arrays.
<?php
declare(strict_types=1);
// PUT /books/4 with multipart/form-data
[$fields, $files] = request_parse_body();
header('Content-Type: application/json');
echo json_encode(['fields' => $fields, 'files' => array_map(fn($f) => [$f['name'], $f['size']], $files)]), "\n";
$ curl -X PUT localhost:8000/books/4 -F title=Dawn -F cover=@cover.jpg
{"fields":{"title":"Dawn"},"files":{"cover":["cover.jpg",16]}}
Source: request_parse_body
The response: status, headers, body
A PHP response is built from three things: the status code (http_response_code(), default 200), the headers
(header()), and everything the script outputs (echo, text outside <?php tags), which becomes the body.
-
header('Name: value')adds or replaces a header;header('Set-Cookie: ...', false)adds a second one instead of replacing;header_remove('X-Powered-By')removes one. -
header('Location: /books/1', true, 303)sets the header and the status in one call. -
headers_list()shows what will be sent;headers_sent()says whether it is too late to change anything.
Headers already sent
Headers travel before the body, so they can only be set until the first byte of output. After that, header()
fails with a warning and does nothing:
<?php
declare(strict_types=1);
echo "Some output\n";
header('X-Too-Late: yes'); // warning: headers already sent
var_dump(headers_sent($file, $line), basename($file), $line);
Some output
Warning: Cannot modify header information - headers already sent by (output started at /app/public/index.php:71) in /app/public/index.php on line 72
bool(true)
string(9) "index.php"
int(71)
Source: header and headers_sent
The usual culprits are not echo statements but invisible output: whitespace or a blank line after a closing ?> in
an included file, a UTF-8 byte-order mark at the start of a file, or a warning printed by display_errors. The fixes:
omit the closing ?> in files that contain only PHP (PER Coding Style requires it), save files without a BOM, and
keep display_errors off in production. The message names the file and line where output started.
Redirects and Post/Redirect/Get
A redirect is a Location header with a 3xx status. After a successful form POST, answer with 303 See Other: the
browser then GETs the new URL, so reloading the page does not resubmit the form (the Post/Redirect/Get pattern).
Use 301/308 for permanent moves (308 keeps the method), 302/307 for temporary ones (307 keeps the method),
and end the script after sending a redirect.
<?php
declare(strict_types=1);
// POST /reviews: save, then redirect
header('Location: /books/1?reviewed=1', true, 303);
exit;
$ curl -i -X POST localhost:8000/reviews -d 'rating=5'
HTTP/1.1 303 See Other
Location: /books/1?reviewed=1
Source: header and RFC 9110 — 303 See Other
Never redirect to a URL taken from the request (header('Location: ' . $_GET['next'])) without checking that it is a
local path: that is an open redirect, used in phishing.
Content negotiation
One URL can serve several representations. The client says what it accepts in Accept; the server picks one and
sends Vary: Accept, so that caches store the representations separately:
<?php
declare(strict_types=1);
// GET /books/{id}
$book = ['id' => 1, 'title' => 'Kindred'];
header('Vary: Accept');
if (str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json')) {
header('Content-Type: application/json');
echo json_encode($book), "\n";
} else {
header('Content-Type: text/html; charset=utf-8');
echo '<h1>', htmlspecialchars($book['title']), "</h1>\n";
}
$ curl localhost:8000/books/1
<h1>Kindred</h1>
$ curl -H 'Accept: application/json' localhost:8000/books/1
{"id":1,"title":"Kindred"}
Source: RFC 9110 — Content Negotiation
A full implementation parses the q weights (text/html;q=0.9, application/json); frameworks do this for you
(Laravel’s $request->wantsJson() and $request->prefers()).
Every response above also carried X-Powered-By: PHP/8.5.11. That header is controlled by expose_php (on by
default) and tells attackers the exact version; php.ini-production still leaves it on, so set expose_php = Off.
Output buffering
Output buffering collects output in memory instead of sending it immediately. ob_start() opens a buffer,
ob_get_clean() returns and discards it. Templates use it to render a view into a string; it also lets a script set
headers after producing output, because nothing has been sent yet.
<?php
declare(strict_types=1);
ob_start();
echo "<p>Hello</p>";
$html = ob_get_clean();
var_dump($html, ob_get_level()); // string(12) "<p>Hello</p>" int(0)
Source: PHP Manual — Output Control and ob_start
The output_buffering INI setting (4096 in php.ini-production) adds a server-level buffer. It hides "headers
already sent" bugs in production that then appear in development, so do not rely on it. Streaming responses (large
downloads, server-sent events) need the opposite: ob_end_flush() the buffers and flush() after each chunk.
Response headers of outgoing requests
When PHP itself makes an HTTP request with the stream wrapper (file_get_contents('https://...')),
http_get_last_response_headers() (8.4) returns the response headers. It replaces the magic $http_response_header
variable, which is deprecated since 8.5. http_clear_last_response_headers() resets it.
<?php
declare(strict_types=1);
$body = file_get_contents('http://localhost:8000/books/1', context: stream_context_create([
'http' => ['header' => "Accept: application/json\r\n"],
]));
var_dump($body); // string(27) "{"id":1,"title":"Kindred"}\n"
print_r(http_get_last_response_headers());
// [0] => HTTP/1.1 200 OK [1] => Host: localhost:8000 ... [5] => Vary: Accept [6] => Content-Type: application/json
Source: http_get_last_response_headers
For real API clients, use cURL or a PSR-18 client; see HTTP Clients, APIs and Email.
URLs: parse_url() and the URI extension
parse_url() splits a URL into parts, but it does not follow any standard, does not validate, and does not
normalise. PHP 8.5 added the always-available URI extension with two parsers: Uri\Rfc3986\Uri (RFC 3986, for
URIs in general) and Uri\WhatWg\Url (the WHATWG URL Standard, what browsers implement). Both are immutable value
objects with with*() methods, and both reject invalid input.
<?php
declare(strict_types=1);
$raw = 'https://ada@bookshelf.test:8443/books/../search?q=le%20guin&page=2#results';
print_r(parse_url($raw)); // [path] => /books/../search: no normalisation
$uri = Uri\Rfc3986\Uri::parse($raw);
var_dump($uri->getHost(), $uri->getPort(), $uri->getPath(), $uri->getRawPath(), $uri->getQuery());
// "bookshelf.test" int(8443) "/search" "/books/../search" "q=le%20guin&page=2"
echo $uri->withQuery('q=butler')->withFragment(null)->toString(), "\n";
// https://ada@bookshelf.test:8443/search?q=butler
echo Uri\Rfc3986\Uri::parse('https://bookshelf.test/books/')->resolve('../authors/7')->toString(), "\n";
// https://bookshelf.test/authors/7
$url = Uri\WhatWg\Url::parse('HTTPS://Bücher.Example/a b?x=1');
var_dump($url->getAsciiHost(), $url->getUnicodeHost(), $url->toAsciiString());
// "xn--bcher-kva.example" "bücher.example" "https://xn--bcher-kva.example/a%20b?x=1"
var_dump(Uri\Rfc3986\Uri::parse('https://exa mple.com')); // NULL: invalid
var_dump(parse_url('https://exa mple.com')); // ["host"] => "exa mple.com": accepted
try {
new Uri\WhatWg\Url('http://[::1');
} catch (Uri\WhatWg\InvalidUrlException $e) {
echo $e->getMessage(), "\n"; // The specified URI is malformed (Ipv6Unclosed)
}
Source: PHP Manual — URI, Uri\Rfc3986\Uri, Uri\WhatWg\Url and parse_url
Use Uri\WhatWg\Url for URLs that come from or go to browsers (it lowercases the scheme, converts international
domain names to Punycode and percent-encodes spaces, as browsers do), and Uri\Rfc3986\Uri for other URI schemes and
strict validation. Checking the host of a user-supplied URL with a real parser, instead of parse_url() or a regular
expression, is part of defending against SSRF (see
Web Security Fundamentals).
PSR-7: requests and responses as objects
Superglobals and header() are global state, which makes code hard to test and to compose. PSR-7 defines
immutable ServerRequestInterface and ResponseInterface objects, and PSR-17 the factories that create them;
micro-frameworks (Slim, Mezzio) and PSR-15 middleware are built on them. The PHP Reference’s
PSR Standards page shows the interfaces with a
working example. Laravel does not use PSR-7 internally: its Illuminate\Http\Request and Response extend Symfony’s
HttpFoundation classes, which are mutable but serve the same purpose, and a controller can still type-hint a PSR-7
request after installing the bridge packages. Building a small PSR-7/PSR-15 application by hand is on
Templating, Routing and Middleware by Hand.
Where older books and tutorials are outdated
-
$_REQUESTfor form data (its order is an exam topic in Beak ch. 9) mixes sources and, withoutrequest_order=GP, cookies; read$_GETor$_POST. -
Hand-parsing
php://inputfor PUT (Beak ch. 9) is replaced byrequest_parse_body()for form and multipart bodies (8.4); JSON is still read fromphp://input. -
$http_response_headerafterfile_get_contents()is deprecated since 8.5; callhttp_get_last_response_headers()(8.4). -
parse_url()to validate URLs was never safe; use the URI extension (8.5) orfilter_var(..., FILTER_VALIDATE_URL)plus an explicit host check. -
302 after a form POST works in browsers, but 303 states the intent; the Post/Redirect/Get pattern itself remains the right one.
References
-
PHP Manual — Core php.ini directives (
request_order,variables_order,expose_php,output_buffering) -
Andrew Beak — PHP 7 Zend Certification Study Guide (Apress, 2017) and David Powers — PHP 7 Solutions, 4th ed. (Apress, 2019)