PHP and the Web: Requests, Forms and Superglobals
How PHP sees an HTTP request: the superglobals, reading form input with GET and POST, validating and escaping it, sending headers and status codes, and redirects.
How PHP sees a request
A browser sends an HTTP request to a web server such as Nginx or Apache. When the requested path belongs to a PHP script, the web server hands the request to PHP — usually to a PHP-FPM worker process, or to PHP built into Apache. PHP then:
- fills the superglobal arrays with the request's data: query string, form fields, cookies, headers;
- runs the script from top to bottom;
-
collects the headers the script set and everything it printed
(with
echo, or as HTML outside the<?php … ?>tags), and returns them to the web server as the HTTP response; - throws away all variables when the script ends.
This "share-nothing" model is why PHP is easy to run: one broken request cannot corrupt the next. It is also why remembering a user between requests needs cookies and sessions. How PHP plugs into the web server is explained on What Is PHP? and Deploying PHP. To try the examples on this page in a browser, the built-in development server is enough:
php -S localhost:8000
The superglobals
A superglobal is one of PHP's
built-in arrays that are available in every scope — inside functions
and methods too, without the global keyword. Most of
them hold data from the current request:
| Superglobal | Contains |
|---|---|
$_GET |
the query string parameters (after ? in the
URL)
|
$_POST |
form fields sent in the body of a POST request |
$_SERVER |
request line, headers, and server and script information |
$_COOKIE |
cookies the browser sent (see Sessions and Cookies) |
$_FILES |
uploaded files (see PHP Security Basics) |
$_SESSION |
session data, after session_start() |
$_ENV |
environment variables (often empty, depending on settings) |
$_REQUEST |
$_GET and $_POST merged, and
depending on php.ini also cookies — avoid it, because you
cannot tell where a value came from
|
Everything in these arrays (except some $_SERVER
entries) comes from the client and can be anything, whatever your
HTML form allows. The general rules for
input validation
apply: check on the server, and prefer whitelists.
$_GET: query parameters
$_GET holds the parameters
of the
query string, decoded from
percent-encoding. It is filled for any request that has a query string, not only
for GET requests. parse_str() applies the same rules to
a string, which shows what PHP does with a URL such as
/outages.php?area=Deventer&q=power+cut%21&meter[]=M-1042&meter[]=M-2210&page=2:
<?php
parse_str("area=Deventer&q=power+cut%21&meter[]=M-1042&meter[]=M-2210&page=2", $query);
var_dump($query);
$_GET = $query; // in a real request, PHP fills $_GET like this
$page = filter_var($_GET['page'] ?? '1', FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
var_dump($page);
array(4) {
["area"]=>
string(8) "Deventer"
["q"]=>
string(10) "power cut!"
["meter"]=>
array(2) {
[0]=>
string(6) "M-1042"
[1]=>
string(6) "M-2210"
}
["page"]=>
string(1) "2"
}
int(2)
Three things to notice:
-
Every value is a string, even
page=2. Convert and validate it yourself, asfilter_var()does here. -
A name ending in
[]becomes an array, so a value you expect to be a string may be an array if someone edits the URL. Code that passes it to a string function then throws aTypeError. -
A missing parameter is simply absent: read it with
??and a default, never directly.
PHP also replaces dots and spaces in parameter names with
underscores: ?meter.id=M-1042 arrives as
$_GET['meter_id'].
$_POST: form data
A form with method="post" sends its fields in the
request body, in the
form encoding
(application/x-www-form-urlencoded, or
multipart/form-data when it uploads files).
$_POST holds those
fields, keyed by the name attribute of each input. A
meter reading form on Kestrel Energy's customer portal might send:
POST /readings/submit.php HTTP/1.1 Host: portal.example.com Content-Type: application/x-www-form-urlencoded Content-Length: 44 customer=1042&meter=M-1042&kwh=412.5&agree=1
PHP turns that body into
['customer' => '1042', 'meter' => 'M-1042', 'kwh' =>
'412.5', 'agree' => '1']. An unchecked checkbox is not sent at all, so its key is missing.
When to use GET or POST is covered in
Forms and Input: GET for reading and searching, POST for anything that changes
data.
$_POST is filled only for those two form encodings. A
JSON request body, as sent by JavaScript's fetch() or
an API client, must be read and decoded yourself:
<?php
$data = json_decode(file_get_contents('php://input'), true, flags: JSON_THROW_ON_ERROR);
$_SERVER: request and server details
$_SERVER holds
information about the request and the environment, filled by the web
server and PHP. Request headers appear with an
HTTP_ prefix, in capitals, with dashes turned into
underscores: Accept-Language becomes
$_SERVER['HTTP_ACCEPT_LANGUAGE'].
| Key | Example value |
|---|---|
REQUEST_METHOD |
POST |
REQUEST_URI |
/outages.php?area=Deventer |
QUERY_STRING |
area=Deventer |
HTTP_HOST |
portal.example.com (sent by the client) |
HTTP_USER_AGENT |
the browser's identification (sent by the client) |
REMOTE_ADDR |
the IP address the connection came from, such as
192.0.2.15
|
HTTPS |
set to a non-empty value when the request used HTTPS |
SCRIPT_FILENAME |
the full path of the script being run |
<?php
// Simulated request; PHP fills these from the HTTP request
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/outages.php?area=Deventer';
$_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'nl-NL,nl;q=0.9,en;q=0.8';
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$language = substr($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? 'en', 0, 2);
$supported = ['en', 'nl'];
echo $_SERVER['REQUEST_METHOD'], " ", $path, "\n";
echo "Language: ", in_array($language, $supported, true) ? $language : 'en', "\n";
GET /outages.php Language: nl
Validating form input
HTML attributes such as required and
type="number" help honest users but protect nothing:
anyone can send a request without your form. The server must check
every field again. A pattern that works well: read each field with a
default, validate it, collect error messages, and only act when the
list of errors is empty.
filter_var() validates and converts common formats. It
returns the converted value, or false when the input is
not valid — so compare with === false, because a valid
0 is falsy too.
<?php
function validateReading(array $input): array
{
$errors = [];
$customer = filter_var($input['customer'] ?? '', FILTER_VALIDATE_INT,
['options' => ['min_range' => 1]]);
if ($customer === false) {
$errors[] = 'Customer number must be a positive whole number.';
}
$meter = $input['meter'] ?? '';
if (!is_string($meter) || !preg_match('/^M-\d{4}$/', $meter)) {
$errors[] = 'Meter ID must look like M-1234.';
}
$kwh = filter_var($input['kwh'] ?? '', FILTER_VALIDATE_FLOAT,
['options' => ['min_range' => 0]]);
if ($kwh === false) {
$errors[] = 'Reading must be a number such as 412.5.';
}
return [compact('customer', 'meter', 'kwh'), $errors];
}
// Two simulated form submissions
$_POST = ['customer' => '1042', 'meter' => 'M-1042', 'kwh' => '412.5'];
[$reading, $errors] = validateReading($_POST);
var_dump($errors === [], $reading['kwh']);
$_POST = ['customer' => '0', 'meter' => 'M-1042', 'kwh' => '412,5'];
[$reading, $errors] = validateReading($_POST);
echo implode("\n", $errors), "\n";
bool(true) float(412.5) Customer number must be a positive whole number. Reading must be a number such as 412.5.
The second submission fails twice: 0 is below the
minimum, and 412,5 uses a decimal comma, which
FILTER_VALIDATE_FLOAT rejects by default. The meter ID
is checked with a
regular expression, and is_string() first rules out an array sent as
meter[]=…. Values you will use in SQL go into a
prepared statement, never into
the query text.
Escaping output with htmlspecialchars()
Validation decides whether input is acceptable; escaping decides how
it is shown. Any value from a request, a database or a file
that you place into HTML must be escaped, or a value containing
<script> becomes part of your page:
cross-site scripting
(XSS).
htmlspecialchars()
replaces the five characters that have a meaning in HTML —
&, <, >,
" and ' — with entities, so the browser
displays them as text.
<?php
function e(string $text): string
{
return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
$_GET = ['area' => 'Deventer <script>alert("hi")</script>'];
$area = $_GET['area'] ?? '';
echo "<p>Outages in " . e($area) . "</p>\n";
echo '<input name="area" value="' . e($area) . '">' . "\n";
<p>Outages in Deventer <script>alert("hi")</script></p> <input name="area" value="Deventer <script>alert("hi")</script>">
Since PHP 8.1 the default flags are
ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, so single
quotes are escaped too and invalid UTF-8 is replaced instead of
producing an empty string. Before PHP 8.1 the default was
ENT_COMPAT, which leaves single quotes alone, and code
that passes ENT_COMPAT explicitly still does; a short
helper like e() makes the safe call the easy one. In
templates, the short echo tag <?= … ?> prints a
value:
<?php $area = $_GET['area'] ?? ''; ?>
<h1>Planned outages</h1>
<p>Area: <?= htmlspecialchars($area) ?></p>
htmlspecialchars() is right for HTML text and quoted
attribute values. Other places need other encoding: values in a URL
need rawurlencode(), data for JavaScript is best passed
with json_encode(), and SQL needs prepared statements.
See
output encoding
for the general principle and
PHP Security Basics for more.
Template engines such as Twig and Laravel's Blade escape output by
default.
Headers and status codes
By default PHP answers with status
200 OK and
Content-Type: text/html; charset=UTF-8.
http_response_code() sets another
status code,
and header() adds or replaces a
response header. A
small JSON endpoint:
<?php
// outages.php — planned outages for one area, as JSON
$area = $_GET['area'] ?? '';
$outages = ['Deventer' => [['from' => '2026-10-12T09:00:00+02:00', 'until' => '2026-10-12T11:00:00+02:00']]];
header('Content-Type: application/json; charset=utf-8');
if (!isset($outages[$area])) {
http_response_code(404);
echo json_encode(['error' => 'Unknown area']);
exit;
}
echo json_encode($outages[$area]);
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
{"error":"Unknown area"}
"Headers already sent"
HTTP sends the headers before the body. As soon as a script outputs
anything — an echo, a blank line or a space before
<?php, an invisible byte order mark at the start of
the file — PHP sends the headers, and from then on
header(), setcookie() and
session_start() can no longer work. PHP logs
Warning: Cannot modify header information - headers already sent
by (output started at …), and the "output started at" part names the file and line to fix.
headers_sent()
tells you whether it has happened:
<?php
$before = headers_sent();
echo "Thank you for your meter reading.\n";
$after = headers_sent($file, $line);
echo "Before: ", var_export($before, true), ", after: ", var_export($after, true), "\n";
echo "Output started on line $line\n";
Thank you for your meter reading. Before: false, after: true Output started on line 3
The fix is to do all header work first and output last. Leaving out
the closing ?> at the end of a file that contains
only PHP code is standard practice, because it makes stray
whitespace after the tag impossible.
Output buffering
Output buffering makes PHP
collect output in memory instead of sending it right away.
ob_start() starts a buffer, and
ob_get_clean() returns what was collected and stops
buffering. Frameworks use it to render a template into a string, and
while output is buffered, headers can still be changed:
<?php
ob_start(); // collect output instead of sending it
echo "<p>Thank you for your meter reading.</p>\n";
$sent = headers_sent(); // still false: nothing has left PHP yet
$body = ob_get_clean(); // take the collected output, stop buffering
echo "Headers sent while buffering: ", var_export($sent, true), "\n";
echo "Buffered ", strlen($body), " bytes: ", $body;
Headers sent while buffering: false Buffered 41 bytes: <p>Thank you for your meter reading.</p>
Many php.ini files, including PHP's own development and production
templates, set output_buffering = 4096: the first 4 KB
of output are buffered automatically. That hides "headers already
sent" bugs until a page prints more than 4 KB before calling
header() — or until the code runs on a server with
another setting. Do not rely on it.
Redirects and Post/Redirect/Get
A redirect is a response with a 3xx status code and a
Location header; the browser then requests the new URL.
header('Location: …') sets status 302 unless you pass
another one, or a 201 or 3xx status was already set.
header() does not stop the script, so always follow a
redirect with exit — otherwise the rest of the script
still runs, and might output a page meant only for logged-in users.
Post/Redirect/Get (PRG) is the pattern for handling forms: the script that receives a valid POST does its work, then answers with a redirect (status 303 See Other) to a normal page, which the browser fetches with GET. If the user refreshes or presses Back, the browser repeats only that harmless GET — not the POST that saved the reading.
<?php
// readings/submit.php
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Allow: POST');
exit;
}
[$reading, $errors] = validateReading($_POST);
if ($errors === []) {
saveReading($reading); // e.g. with PDO
header('Location: /readings/thanks.php', true, 303);
exit;
}
// otherwise: show the form again, with the escaped errors and values
POST /readings/submit.php → 303 See Other
Location: /readings/thanks.php
GET /readings/thanks.php → 200 OK (refresh repeats only this)
When validation fails, there is nothing to redirect to: show the form again with status 422 or 200, the error messages, and the submitted values (escaped) so the user does not have to type everything again.