Validation and Escaping

Two rules keep web applications safe and correct: validate all input (never trust what the browser sends) and escape all output (never let data be interpreted as code). This lesson shows how to validate form data with PHP's filter functions and your own rules, and how escaping with htmlspecialchars prevents cross-site scripting attacks.

Why escaping matters: XSS

Suppose a page greets visitors using a value from the URL, without escaping it:

PHP hello.php
<?php $name = $_GET['name'] ?? 'guest'; ?>
<p>Hello, <?= $name ?>!</p>
HTTP
GET /hello.php?name=Ana
GET /hello.php?name=<script>alert(document.cookie)</script>
Responses
> GET /hello.php?name=Ana
<p>Hello, Ana!</p>
> GET /hello.php?name=<script>alert(document.cookie)</script>
<p>Hello, <script>alert(document.cookie)</script>!</p>

An attacker can send someone a link like the second one, and the browser runs the injected JavaScript as if it came from your site — it can read cookies, change the page or act as the logged-in user. This is cross-site scripting (XSS), one of the most common web vulnerabilities. The same happens with data stored in a database — a malicious comment, product review or user name.

The fix: htmlspecialchars

htmlspecialchars converts the characters that have meaning in HTML — &, <, >, " and ' — into entities, so they display as text instead of becoming markup:

PHP hello.php
<?php
function e(?string $value): string
{
    return htmlspecialchars($value ?? '', ENT_QUOTES, 'UTF-8');
}

$name = $_GET['name'] ?? 'guest';
?>
<p>Hello, <?= e($name) ?>!</p>
<input value="<?= e($name) ?>">
HTTP
GET /hello.php?name=<script>alert(1)</script>
GET /hello.php?name=Tom%22%20onfocus=%22alert(1)
Responses
> GET /hello.php?name=<script>alert(1)</script>
<p>Hello, &lt;script&gt;alert(1)&lt;/script&gt;!</p>
<input value="&lt;script&gt;alert(1)&lt;/script&gt;">
> GET /hello.php?name=Tom%22%20onfocus=%22alert(1)
<p>Hello, Tom&quot; onfocus=&quot;alert(1)!</p>
<input value="Tom&quot; onfocus=&quot;alert(1)">

A short helper like e() makes escaping easy enough to do everywhere — which is the point: escape every value at the moment you output it into HTML, whether it came from a form, the URL, a database or a file. Template engines such as Twig and Laravel's Blade escape automatically.

Escaping depends on context: HTML text and attributes use htmlspecialchars; values in URLs use urlencode/rawurlencode; data for JavaScript is best passed with json_encode; SQL uses prepared statements (lesson 58).

PHP
<?php

$search = "fish & chips";
echo '<a href="/search?q=' . urlencode($search) . '">', htmlspecialchars($search), "</a>\n";

$user = ['name' => 'O\'Brien </script>', 'id' => 7];
echo '<script>const user = ', json_encode($user, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT), ";</script>\n";
Output
<a href="/search?q=fish+%26+chips">fish &amp; chips</a>
<script>const user = {"name":"O\u0027Brien \u003C\/script\u003E","id":7};</script>

Validating with filter_var

Validation checks that input has the right shape before you use it. filter_var handles the common formats and returns the cleaned value, or false if it's invalid:

PHP
<?php

$tests = [
    ['ana@example.com', FILTER_VALIDATE_EMAIL],
    ['not-an-email', FILTER_VALIDATE_EMAIL],
    ['https://example.com/page', FILTER_VALIDATE_URL],
    ['42', FILTER_VALIDATE_INT],
    ['4.2', FILTER_VALIDATE_FLOAT],
    ['yes', FILTER_VALIDATE_BOOL],
    ['192.168.1.10', FILTER_VALIDATE_IP],
];

foreach ($tests as [$value, $filter]) {
    echo str_pad($value, 26), var_export(filter_var($value, $filter), true), "\n";
}

var_dump(filter_var('150', FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 100]]));
Output
ana@example.com           'ana@example.com'
not-an-email              false
https://example.com/page  'https://example.com/page'
42                        42
4.2                       4.2
yes                       true
192.168.1.10              '192.168.1.10'
bool(false)

Note that validated numbers come back as real ints and floats.

A complete validated form

A typical approach: read and trim every field, check each rule, collect error messages per field, and only act when there are no errors. On failure, redisplay the form with the errors and the user's previous input:

PHP register.php
<?php
function e(?string $v): string { return htmlspecialchars($v ?? '', ENT_QUOTES); }

$input = ['name' => '', 'email' => '', 'age' => '', 'plan' => 'free'];
$errors = [];
$plans = ['free', 'pro', 'team'];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    foreach ($input as $field => $default) {
        $input[$field] = trim((string) ($_POST[$field] ?? $default));
    }

    if (mb_strlen($input['name']) < 2) {
        $errors['name'] = 'Name must be at least 2 characters.';
    }
    if (!filter_var($input['email'], FILTER_VALIDATE_EMAIL)) {
        $errors['email'] = 'Enter a valid email address.';
    }
    $age = filter_var($input['age'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 13, 'max_range' => 120]]);
    if ($age === false) {
        $errors['age'] = 'Age must be a whole number from 13 to 120.';
    }
    if (!in_array($input['plan'], $plans, true)) {
        $errors['plan'] = 'Choose a valid plan.';
    }

    if ($errors === []) {
        echo "<p>Welcome, " . e($input['name']) . " ({$input['plan']} plan)!</p>";
        exit;
    }
}
?>
<form method="post">
<?php foreach (['name', 'email', 'age'] as $field): ?>
  <input name="<?= $field ?>" value="<?= e($input[$field]) ?>"><?php if (isset($errors[$field])): ?> <span class="error"><?= $errors[$field] ?></span><?php endif; ?>

<?php endforeach; ?>
</form>
HTTP
POST /register.php name=A&email=ana@&age=9&plan=free
POST /register.php name=Ana&email=ana@example.com&age=34&plan=enterprise
POST /register.php name=Ana&email=ana@example.com&age=34&plan=pro
Responses
> POST /register.php
<form method="post">
  <input name="name" value="A"> <span class="error">Name must be at least 2 characters.</span>
  <input name="email" value="ana@"> <span class="error">Enter a valid email address.</span>
  <input name="age" value="9"> <span class="error">Age must be a whole number from 13 to 120.</span>
</form>
> POST /register.php
<form method="post">
  <input name="name" value="Ana">
  <input name="email" value="ana@example.com">
  <input name="age" value="34">
</form>
> POST /register.php
<p>Welcome, Ana (pro plan)!</p>

The second request was rejected because enterprise isn't an offered plan — someone tampered with the form. (A real page would also show that error near the plan selector.) HTML attributes like required and type="email" improve the experience in the browser, but anyone can bypass them, so server-side validation is mandatory.

Validate input, escape output

  • Validate as data comes in: right type, length, format and range; values from a fixed list are really in the list.
  • Normalise where it helps: trim spaces, lower-case emails, convert numbers to int.
  • Don't "sanitise" away problems by stripping characters silently — reject invalid input with a clear message instead.
  • Escape on output, for the context you're writing into. Store the original data; escape it when displaying.

Try it yourself

Add validation to a comment form: the comment must be 5–500 characters, and the rating must be an integer from 1 to 5.

Try it yourself · comment.php
Edit the code, copy it into comment.php, then start PHP's built-in web server with php -S localhost:8000 in that folder and open http://localhost:8000/comment.php (PHP 8.5). How to install PHP