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:

  1. fills the superglobal arrays with the request's data: query string, form fields, cookies, headers;
  2. runs the script from top to bottom;
  3. 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;
  4. throws away all variables when the script ends.
One PHP request. 1: the browser sends GET with the query string area=Deventer to the web server. 2: the web server passes the request to PHP-FPM over FastCGI. 3: PHP fills $_GET, $_POST and $_SERVER and runs the script. 4: PHP returns the headers and the printed output to the web server. 5: the web server sends the HTTP response to the browser. 6: PHP discards the script's variables. Browser Web server PHP-FPM 1. GET ?area=Deventer 2. FastCGI request 3. $_GET, $_POST, $_SERVER filled; script runs 4. headers + output 5. HTTP response 6. variables discarded
Each request starts with empty memory: PHP keeps nothing from the previous request unless you store it somewhere — a database, a file, or a session.

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, as filter_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 a TypeError.
  • 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 &lt;script&gt;alert(&quot;hi&quot;)&lt;/script&gt;</p>
<input name="area" value="Deventer &lt;script&gt;alert(&quot;hi&quot;)&lt;/script&gt;">

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.