What you'll learn
Secure input handling is a pipeline, not one magic function. You'll distinguish validation, normalization, conversion, and context-specific escaping—and design errors people can actually fix.
- Identify external trust boundaries
- Collect values without type assumptions
- Validate syntax and business rules
- Represent errors by field
- Escape data for HTML text and attribute contexts
- Carry only validated values into application logic
Everything outside the process is untrusted
Form fields, query strings, headers, cookies, uploaded files, API responses, database records, and environment configuration may be absent, malformed, stale, or hostile. Trust begins only after your application proves a value meets a specific contract.
Sanitization is not validation
| Operation | Question it answers | Example |
|---|---|---|
| Normalize | Can equivalent representations be made consistent? | Trim surrounding spaces from a display name |
| Validate | Does this value satisfy the contract? | Age is an integer from 13 to 120 |
| Convert | What runtime type should represent it? | Validated digit text becomes an integer |
| Escape | How can this data be safely inserted here? | Encode special characters in HTML text |
Use a repeatable input pipeline
STEP 1
Collect
Read optional raw values without assumptions.
STEP 2
Normalize
Trim or canonicalize only when the domain allows it.
STEP 3
Validate
Prove values satisfy syntax and business rules.
STEP 4
Convert
Create the types the application actually needs.
STEP 5
Use
Pass trusted values into the domain operation.
STEP 6
Escape
Encode data for its final output context.
Key idea
Validate field types and rules
<?php
declare(strict_types=1);
$raw = [
'name' => $_POST['name'] ?? null,
'email' => $_POST['email'] ?? null,
'age' => $_POST['age'] ?? null,
];
$name = is_string($raw['name']) ? trim($raw['name']) : '';
$email = is_string($raw['email']) ? trim($raw['email']) : '';
$age = filter_var($raw['age'], FILTER_VALIDATE_INT, [
'options' => ['min_range' => 13, 'max_range' => 120],
]);
$errors = [];
if ($name === '' || mb_strlen($name) > 80) {
$errors['name'] = 'Enter a name between 1 and 80 characters.';
}
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
$errors['email'] = 'Enter a valid email address.';
}
if ($age === false) {
$errors['age'] = 'Age must be a whole number from 13 to 120.';
}Notice the order: confirm a raw field is a string before trimming it; validate age before treating it as an integer; store errors by the same keys used by the form.
| Input | Useful starting point |
|---|---|
FILTER_VALIDATE_EMAIL | |
| Integer range | FILTER_VALIDATE_INT + min_range/max_range |
| URL | FILTER_VALIDATE_URL plus an allowed-scheme rule |
| Enum-like choice | in_array($value, $allowed, true) |
| Date | Parse strictly, then verify the parsed components |
| Free text | Type, required/optional rule, and a domain length limit |
Note
Design errors around recovery
A useful error identifies the field and tells the user how to fix it without exposing internal implementation. Keep field errors separate from a form-level error such as “We could not save your changes.”
<?php
function errorFor(array $errors, string $field): ?string
{
$message = $errors[$field] ?? null;
return is_string($message) ? $message : null;
}
$emailError = errorFor($errors, 'email');
?>
<?php if ($emailError !== null): ?>
<p id="email-error" role="alert">
<?= htmlspecialchars($emailError, ENT_QUOTES, 'UTF-8') ?>
</p>
<?php endif; ?>Escape at the last responsible moment
For HTML text and quoted attributes, use htmlspecialchars() with quotes enabled and an explicit encoding. Keep raw validated values internally; escaping too early causes double-encoding and couples domain data to one output format.
<?php
function e(?string $value): string
{
return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
$displayName = '<strong>Maya</strong>';
?>
<p>Welcome, <?= e($displayName) ?></p>
<input value="<?= e($displayName) ?>">Context changes the encoder
Complete registration-validation boundary
<?php
declare(strict_types=1);
$errors = [];
$email = '';
function e(?string $value): string
{
return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
$email = is_string($_POST['email'] ?? null)
? trim($_POST['email'])
: '';
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
$errors['email'] = 'Enter a valid email address.';
}
if ($errors === []) {
// Pass $email to the registration service.
header('Location: /welcome', true, 303);
exit;
}
}
?>
<form method="post" novalidate>
<label for="email">Email</label>
<input id="email" name="email" type="email"
value="<?= e($email) ?>"
aria-describedby="email-error">
<?php if (isset($errors['email'])): ?>
<p id="email-error" role="alert"><?= e($errors['email']) ?></p>
<?php endif; ?>
<button>Create account</button>
</form>Tip
novalidate during development makes it easy to exercise server-side errors. In the finished interface, browser validation can provide faster feedback while the server remains authoritative.Recap & quick check
Key takeaways
- External data stays untrusted until it satisfies a specific contract.
- Collect, normalize, validate, convert, use, then escape for the output context.
- Sanitization changes data; validation accepts or rejects it.
- Field-keyed errors make accessible redisplay and testing simpler.
- Keep validated internal data raw and escape only when inserting it into a destination.
Quick check
1. What does validation prove?
2. When should HTML escaping normally occur?
3. Why validate before casting?
4. Which structure best supports field-level errors?