Forms, Validation and Output Escaping
|
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; FILTER_THROW_ON_FAILURE needs PHP 8.5, and
everything else works on 8.3 and 8.4. The examples build the review form of Bookshelf Lite, this section’s
framework-free application, and every output shown was produced by running it behind php -S and sending requests
with curl. Laravel does the same work with form requests and Blade’s {{ }}; see
Validation and Form Requests and
Blade Templates.
GET, POST and Post/Redirect/Get
-
GET requests read; their parameters are in the URL (bookmarkable, cached, logged). Use GET for search and filtering forms.
-
POST requests change something; the fields travel in the body. Use POST (or PUT, PATCH, DELETE from JavaScript) for anything that creates, updates or deletes.
A POST handler has three outcomes: the input is invalid (show the form again, with the user’s values and the errors,
and status 422), the input is valid (do the work, then redirect with 303 See Other), or the request is not
allowed (403, 404). The redirect is the Post/Redirect/Get pattern: the browser ends on a GET page, so reloading
it does not repeat the POST. The Bookshelf Lite front controller:
<?php
// public/index.php
declare(strict_types=1);
require __DIR__ . '/../src/helpers.php';
require __DIR__ . '/../src/ReviewForm.php';
$book = ['id' => 1, 'title' => 'Kindred </script><b>']; // a hostile title, to show escaping
$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if ($path === '/books/1/reviews/new' && $method === 'GET') {
echo render('review-form', ['book' => $book, 'form' => new ReviewForm()]);
return;
}
if ($path === '/books/1/reviews' && $method === 'POST') {
$form = new ReviewForm();
if (!$form->validate($_POST)) {
http_response_code(422); // Unprocessable Content
echo render('review-form', ['book' => $book, 'form' => $form]);
return;
}
// ... save the review (see "Databases with PDO"), then Post/Redirect/Get
redirect('/books/1?reviewed=1');
}
http_response_code(404);
echo "Not found\n";
<?php
// src/helpers.php
declare(strict_types=1);
/** Escape for HTML text and quoted attribute values. */
function e(string|int|float|null $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
}
/** Render a PHP template with its own variables and return the HTML. */
function render(string $template, array $vars = []): string
{
extract($vars, EXTR_SKIP);
ob_start();
require __DIR__ . "/../templates/$template.php";
return ob_get_clean();
}
function redirect(string $path, int $status = 303): never
{
header("Location: $path", true, $status);
exit;
}
Source: PHP Manual — Dealing with Forms, htmlspecialchars and header
CSRF protection (a hidden token in every POST form) is left out here to keep the example short; it is on Web Security Fundamentals and must be added to every real form.
Validating input
Validation decides whether input is acceptable: present when required, of the right type, within range, one of the allowed values. It never changes the input into something else. The rules for Bookshelf Lite’s review form are a rating from 1 to 5, a body of at least 10 characters, optional genres from a fixed list, and an optional email.
<?php
// src/ReviewForm.php
declare(strict_types=1);
final class ReviewForm
{
private const array EXPECTED = ['rating', 'body', 'genres', 'email'];
private const array GENRES = ['fiction', 'science-fiction', 'history', 'poetry'];
/** @var array<string, string> */
public array $errors = [];
/** @var array<string, mixed> */
public array $values = ['rating' => '', 'body' => '', 'genres' => [], 'email' => ''];
public function validate(array $input): bool
{
// 1. Reject fields the form never had (mass-assignment and tampering)
foreach (array_diff(array_keys($input), self::EXPECTED) as $field) {
$this->errors[$field] = 'Unexpected field.';
}
// 2. Keep the raw values for redisplay ("sticky" form); escape them only on output
$this->values['rating'] = (string) ($input['rating'] ?? '');
$this->values['body'] = trim((string) ($input['body'] ?? ''));
$this->values['genres'] = is_array($input['genres'] ?? null) ? $input['genres'] : [];
$this->values['email'] = trim((string) ($input['email'] ?? ''));
// 3. Validate each field: required, type, range, allowed values
$rating = filter_var($this->values['rating'], FILTER_VALIDATE_INT,
['options' => ['min_range' => 1, 'max_range' => 5]]);
if ($rating === false) {
$this->errors['rating'] = 'Choose a rating from 1 to 5.';
}
if (mb_strlen($this->values['body']) < 10) {
$this->errors['body'] = 'Write at least 10 characters.';
}
if (array_diff($this->values['genres'], self::GENRES) !== []) {
$this->errors['genres'] = 'Unknown genre.';
}
if ($this->values['email'] !== '' && filter_var($this->values['email'], FILTER_VALIDATE_EMAIL) === false) {
$this->errors['email'] = 'Enter a valid email address, or leave it empty.';
}
return $this->errors === [];
}
/** @return list<string> */
public static function genres(): array
{
return self::GENRES;
}
}
Source: PHP Manual — Data Filtering, filter_var and Filter constants
The two whitelists matter as much as the per-field rules. The expected-fields check rejects a crafted
is_admin=1 that a "save every POST field" handler would happily store (the mass-assignment bug); the allowed
values check rejects a genre the form never offered. A client can send any field with any value, whatever the HTML
form contains.
Form arrays: a field named genres[] arrives as a PHP array ($_POST['genres'] === ['poetry', 'horror']), and a
<select multiple name="genres[]"> works the same way. Always check is_array() (or the opposite) before use: a
client can send genres=x instead, and PHP then delivers a string.
A submission with an out-of-range rating, a script in the body, an unknown genre and a smuggled field:
$ curl -w '\nstatus %{http_code}\n' -X POST localhost:8000/books/1/reviews \
--data-urlencode 'rating=9' --data-urlencode 'body=<script>alert(1)</script>' \
-d 'genres[]=poetry&genres[]=horror&is_admin=1' --data-urlencode 'email=ada@example.com'
<li>is_admin: Unexpected field.</li>
<li>rating: Choose a rating from 1 to 5.</li>
<li>genres: Unknown genre.</li>
<label>Review <textarea name="body"><script>alert(1)</script></textarea></label>
<label>Email (optional) <input type="email" name="email" value="ada@example.com"></label>
status 422
$ curl -i -X POST localhost:8000/books/1/reviews -d 'rating=5&genres[]=fiction' --data-urlencode 'body=A remarkable novel.'
HTTP/1.1 303 See Other
Location: /books/1?reviewed=1
The body was not "cleaned": it is shown back exactly as typed, but escaped, so the browser displays the text instead of running it. That is the whole idea of the next section.
The filter extension
filter_var($value, $filter, $options) validates (or, with FILTER_SANITIZE_*, transforms) one value;
filter_input(INPUT_POST, 'rating', ...) reads straight from the request, and filter_var_array() /
filter_input_array() apply a definition to many fields. The validating filters return the converted value, or
false on failure; FILTER_NULL_ON_FAILURE returns null instead, which is needed for FILTER_VALIDATE_BOOL
(where false is a valid result). PHP 8.5’s FILTER_THROW_ON_FAILURE throws instead.
<?php
declare(strict_types=1);
var_dump(filter_var('42', FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 5]])); // bool(false)
var_dump(filter_var('4', FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 5]])); // int(4)
var_dump(filter_var('yes', FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE)); // bool(true)
var_dump(filter_var('maybe', FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE)); // NULL
var_dump(filter_var("ada@example.com\r\nBcc: x@evil.test", FILTER_VALIDATE_EMAIL)); // bool(false)
try {
filter_var('x', FILTER_VALIDATE_INT, FILTER_THROW_ON_FAILURE); // 8.5
} catch (Filter\FilterFailedException $e) {
echo $e->getMessage(), "\n"; // filter validation failed: filter int not satisfied by 'x'
}
var_dump(filter_var('javascript:alert(1)', FILTER_VALIDATE_URL)); // bool(false)
var_dump(filter_var('javascript://comment%0Aalert(1)', FILTER_VALIDATE_URL)); // string(31): accepted!
The last line is a warning about FILTER_VALIDATE_URL: it checks syntax, and javascript://comment%0Aalert(1) is
syntactically a URL that runs script when used as a link. When a user supplies a URL, also check the scheme against
an allowlist (https and perhaps http), for example with the URI extension’s getScheme(), before putting it in an
href.
Escaping output for its context
Cross-site scripting (XSS) happens when untrusted text is placed in a page in a way that the browser interprets as markup or code. The defence is to escape on output, for the context the value lands in. One function does not fit all contexts:
| Context | Example | Escape with |
|---|---|---|
HTML text |
|
|
Quoted HTML attribute |
|
the same, and always quote the attribute |
URL query parameter |
|
|
Whole URL from a user |
|
validate the scheme first (no |
JavaScript value |
|
|
CSS, unquoted attributes, event handlers ( |
— |
do not put untrusted data there |
The review form’s template applies them all. The book title is deliberately hostile (Kindred </script><b>):
<?php /** @var ReviewForm $form */ /** @var array{id:int,title:string} $book */ ?>
<h1>Review <?= e($book['title']) ?></h1>
<?php if ($form->errors): ?>
<ul class="errors">
<?php foreach ($form->errors as $field => $message): ?>
<li><?= e($field) ?>: <?= e($message) ?></li>
<?php endforeach ?>
</ul>
<?php endif ?>
<form method="post" action="/books/<?= (int) $book['id'] ?>/reviews">
<label>Rating
<select name="rating">
<?php foreach (range(1, 5) as $n): ?>
<option value="<?= $n ?>" <?= $form->values['rating'] === (string) $n ? 'selected' : '' ?>><?= $n ?></option>
<?php endforeach ?>
</select>
</label>
<label>Review <textarea name="body"><?= e($form->values['body']) ?></textarea></label>
<?php foreach (ReviewForm::genres() as $genre): ?>
<label><input type="checkbox" name="genres[]" value="<?= e($genre) ?>"
<?= in_array($genre, $form->values['genres'], true) ? 'checked' : '' ?>> <?= e($genre) ?></label>
<?php endforeach ?>
<label>Email (optional) <input type="email" name="email" value="<?= e($form->values['email']) ?>"></label>
<button>Send</button>
</form>
<a href="/search?<?= e(http_build_query(['q' => $book['title'], 'sort' => 'year'])) ?>">More like this</a>
<script>const book = <?= json_encode($book, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR) ?>;</script>
<h1>Review Kindred </script><b></h1>
...
<a href="/search?q=Kindred+%3C%2Fscript%3E%3Cb%3E&sort=year">More like this</a>
<script>const book = {"id":1,"title":"Kindred <\/script><b>"};</script>
In the JavaScript context, htmlspecialchars() would be wrong (the browser does not decode entities inside
<script>), and plain json_encode() would be dangerous: without JSON_HEX_TAG, the title’s </script> would end
the script element early. The sticky form works the same way in reverse: the user’s values are kept raw in
$form->values and escaped only when printed, so a rejected submission is redisplayed exactly as typed (the checkbox
for poetry comes back checked, the 4 option comes back selected).
Why each htmlspecialchars() flag: ENT_QUOTES escapes ' as well as " (the default since PHP 8.1, but stating it
keeps the helper explicit), ENT_SUBSTITUTE replaces invalid UTF-8 instead of returning an empty string, and the
explicit 'UTF-8' charset avoids depending on default_charset. Escape with one helper (e() here, {{ }} in Blade)
everywhere, so that a missing escape is visible in review.
Why "sanitize on input" is wrong
Older books "clean" input as it arrives, with strip_tags(), htmlentities(), addslashes() or
FILTER_SANITIZE_STRING, and then store and print the result. This mixes up two different jobs:
-
the same value goes to many contexts (HTML, an attribute, a URL, JSON, SQL, an email, a CSV file), and each needs different treatment; escaping on input picks one context and gets the others wrong;
-
it corrupts the data:
O'BrienbecomesO'Brienin the database, a review about<template>elements loses its words, and a later double escaping prints&#39;; -
it gives a false sense of safety:
FILTER_SANITIZE_STRINGdid not make values safe in attributes or JavaScript.
FILTER_SANITIZE_STRING (and FILTER_SANITIZE_STRIPPED) are deprecated since PHP 8.1:
<?php
var_dump(filter_var('<b>Hi</b> & "you"', FILTER_SANITIZE_STRING));
// Deprecated: Constant FILTER_SANITIZE_STRING is deprecated since 8.1, use htmlspecialchars() instead
// string(18) "Hi & "you""
The correct split: validate on input (reject what is not acceptable), store the original value, escape on output for each context, and use prepared statements for SQL (see Databases with PDO).
Sending mail without header injection
A contact or notification form often puts user input into an email. If that input reaches an email header (the
subject, From, Reply-To), a line break lets an attacker add headers of their own, such as Bcc: with thousands
of addresses: email header injection, which turns the form into a spam relay. With mail(), passing the headers as
a string is unsafe; the array form (since PHP 7.2) rejects line breaks:
<?php
declare(strict_types=1);
$email = "ada@example.com\r\nBcc: victim@example.net"; // from the form
// Array headers: PHP refuses CR and LF in header values
try {
mail('editor@bookshelf.test', 'New review', 'Body', ['Reply-To' => $email]);
} catch (ValueError $e) {
echo $e->getMessage(), "\n";
// Header "Reply-To" contains CRLF characters that are used as a line separator and are not allowed in the header
}
// String headers: the injected Bcc goes through
mail('editor@bookshelf.test', 'New review', 'Body', "Reply-To: $email");
To: editor@bookshelf.test
Subject: New review
Reply-To: ada@example.com
Bcc: victim@example.net
Body
Source: mail
The output above is what PHP handed to the mail transport (captured with sendmail_path=/bin/cat). Validate addresses
with FILTER_VALIDATE_EMAIL (which rejects the injected value) and, better, send mail through a library: Symfony
Mailer, which Laravel uses, refuses the same value with Email address contains control characters., builds correct
MIME messages and talks SMTP directly. Mail is covered on
HTTP Clients, APIs and Email.
Where older books and tutorials are outdated
-
FILTER_SANITIZE_STRINGas an XSS defence (Beak ch. 6 and 12, Kromann ch. 13) is deprecated since 8.1 and was never a correct defence; validate on input and escape on output. -
$_SERVER['PHP_SELF']echoed in a form’saction(Kromann ch. 13, 17 and 32) is an XSS hole, because the path part is client-controlled: escape it, or use a fixed action URL. Powers ch. 6 explains the problem and its fix. -
"Required" and "expected" field lists (Powers ch. 6) are still the right idea; the modern form is an explicit whitelist plus per-field validation, as above, or a validation library or framework.
-
htmlentities()without flags and charset worked differently before PHP 8.1, when single quotes were not escaped by default; passENT_QUOTES | ENT_SUBSTITUTEand'UTF-8'explicitly. -
mail()with headers built by string concatenation needs the array form, or Symfony Mailer; Powers ch. 6 explains the injection risk, and Kromann’s networking chapters recommend PHPMailer, which remains a valid choice.
References
-
PHP Manual — Dealing with Forms and Variables From External Sources
-
PHP Manual — Data Filtering: validation filters, flags, filter_var, filter_input
-
RFC: FILTER_THROW_ON_FAILURE (PHP 8.5)
-
OWASP — Cross Site Scripting Prevention Cheat Sheet and Input Validation Cheat Sheet
-
Andrew Beak — PHP 7 Zend Certification Study Guide (Apress, 2017), David Powers — PHP 7 Solutions, 4th ed. (Apress, 2019) and Frank M. Kromann — Beginning PHP and MySQL, 5th ed. (Apress, 2018)