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

$_GET

The query-string parameters (for any method, not only GET)

$_POST

The body fields of a request with application/x-www-form-urlencoded or multipart/form-data content, POST only (see request_parse_body() below for PUT and PATCH)

$_FILES

Uploaded files of a multipart/form-data POST (see File Uploads and Images)

$_COOKIE

The cookies the client sent

$_SERVER

The request line, headers as HTTP_* keys (HTTP_ACCEPT, HTTP_USER_AGENT), REQUEST_METHOD, REQUEST_URI, QUERY_STRING, CONTENT_TYPE, REMOTE_ADDR, HTTPS, and server information

$_REQUEST

$_GET, $_POST and possibly $_COOKIE merged; avoid it

$_ENV

Environment variables, only if variables_order contains E (it does not in php.ini-production); use getenv() instead

$_SESSION

The session data, after session_start() (see Sessions and Cookies)

<?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:     */*

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"}

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]}}

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

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"}

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)

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

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)
}

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

  • $_REQUEST for form data (its order is an exam topic in Beak ch. 9) mixes sources and, without request_order=GP, cookies; read $_GET or $_POST.

  • Hand-parsing php://input for PUT (Beak ch. 9) is replaced by request_parse_body() for form and multipart bodies (8.4); JSON is still read from php://input.

  • $http_response_header after file_get_contents() is deprecated since 8.5; call http_get_last_response_headers() (8.4).

  • parse_url() to validate URLs was never safe; use the URI extension (8.5) or filter_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.