Form Validation & Escaping
A form is an open door into your application. Most visitors type sensible values, but some make mistakes, and a few send deliberately harmful data. Your PHP code must check everything that arrives and make sure nothing it prints can break the page.
In this lesson you will learn the two jobs that keep form handling safe: validation, which checks input on the way in, and escaping, which makes data safe on the way out. You will finish with a complete form that validates each field and re-displays the visitor's values securely. This lesson builds on Handling Forms.
Client-side checks are not enough
HTML can check fields before a form is sent: required, type="email", min, max and pattern. You learned these in Built-in Form Validation. They are good for visitors, because mistakes are shown instantly, without a page reload.
They are not security. Anyone can remove the required attribute with the browser's developer tools, or skip your form completely and send a request with curl or a script. The server must check every value again, every time. Think of HTML checks as a convenience and PHP checks as the real rule.
Validate input, escape output
Two different jobs are often confused:
- Validation decides whether input is acceptable. Is the email address well formed? Is the age a whole number between 16 and 60? If not, you reject the value and show an error. You do not try to "repair" it.
- Escaping makes a value safe for the place where you put it. In HTML, that means
htmlspecialchars(). In SQL, it means prepared statements, as shown in CRUD with Prepared Statements.
Validate when data comes in. Escape when data goes out, at the moment you print it. Store the original, unescaped value; escaped text in a database causes double-escaping problems later.
Trimming and required fields
Start every text field the same way: make sure it is a string, then remove spaces from both ends with trim(). A name made only of spaces should count as empty. For length limits, use mb_strlen() so that Hindi and other non-English names are measured in characters, not bytes, as explained in Strings.
<?php
declare(strict_types=1);
function cleanText(mixed $value): string
{
return is_string($value) ? trim($value) : '';
}
$name = cleanText(' ');
$city = cleanText(' Kochi ');
$forged = cleanText(['not', 'a', 'string']);
var_dump($name === '');
var_dump($city);
var_dump($forged);
var_dump(mb_strlen('अनन्या') <= 60);bool(true) string(5) "Kochi" string(0) "" bool(true)
The helper accepts mixed because a request value can be a string, an array or missing. Whatever arrives, you get back a trimmed string.
Validating with filter_var()
filter_var() checks a value against a built-in filter. It returns the value (converted to the right type) when it is valid, and false when it is not:
<?php
declare(strict_types=1);
$ageRange = ['options' => ['min_range' => 16, 'max_range' => 60]];
var_dump(filter_var('meera@example.in', FILTER_VALIDATE_EMAIL));
var_dump(filter_var('meera@example', FILTER_VALIDATE_EMAIL));
var_dump(filter_var('42', FILTER_VALIDATE_INT, $ageRange));
var_dump(filter_var('12', FILTER_VALIDATE_INT, $ageRange));
var_dump(filter_var('42abc', FILTER_VALIDATE_INT));
var_dump(filter_var('4.5', FILTER_VALIDATE_FLOAT));
var_dump(filter_var('https://example.com/page', FILTER_VALIDATE_URL));string(16) "meera@example.in" bool(false) int(42) bool(false) bool(false) float(4.5) string(24) "https://example.com/page"
Notice that FILTER_VALIDATE_INT returns a real int, 42, not the string '42', so you get validation and conversion in one step. Always compare the result with === false. A plain if (!$age) would also reject a valid 0.
FILTER_VALIDATE_EMAIL only checks the format of an address. It cannot tell whether the mailbox exists; for that you send a confirmation email. For checkboxes and yes/no values, FILTER_VALIDATE_BOOL with the flag FILTER_NULL_ON_FAILURE returns true for values like 'yes' and '1', false for 'no' and '0', and null for anything else.
Reading and validating with filter_input()
filter_input() reads a value straight from the request and validates it in one call. The first argument says which input to read: INPUT_GET, INPUT_POST or INPUT_COOKIE.
<?php
declare(strict_types=1);
$age = filter_input(INPUT_POST, 'age', FILTER_VALIDATE_INT, [
'options' => ['min_range' => 16, 'max_range' => 60],
]);
if ($age === null) {
$error = 'Please enter your age.';
} elseif ($age === false) {
$error = 'Age must be a whole number from 16 to 60.';
}filter_input() has three possible results: null when the field was not sent, false when it was sent but is invalid, and the clean value otherwise. That lets you show a different message for "missing" and "wrong".
Two things to know: filter_input() reads the original request, so changes you make to $_POST in your code do not affect it, and it returns null when you run a script in the terminal. Many developers prefer reading $_POST and passing values to filter_var(), which is easier to test.
Patterns with preg_match()
For formats that have no built-in filter, such as an Indian mobile number or PIN code, use a regular expression with preg_match(). It returns 1 for a match and 0 for no match:
'/^[6-9][0-9]{9}$/'matches a 10-digit mobile number starting with 6, 7, 8 or 9.'/^[1-9][0-9]{5}$/'matches a 6-digit PIN code that does not start with 0.
The ^ and $ anchors make the pattern match the whole value, not just part of it. Without them, 'abc9876543210xyz' would pass.
Escaping output with htmlspecialchars()
Any value that came from a visitor, or from a database that visitors write to, must be escaped before you put it into HTML. htmlspecialchars() converts the five characters that have special meaning in HTML into harmless entities:
<?php
declare(strict_types=1);
$name = 'Rohan "Ro" O\'Brien <b>&</b>';
echo htmlspecialchars($name), "\n";
echo '<input name="name" value="' . htmlspecialchars($name) . '">', "\n";Rohan "Ro" O'Brien <b>&</b> <input name="name" value="Rohan "Ro" O'Brien <b>&</b>">
The browser shows the name exactly as typed, quotes and all, but none of it can act as HTML. Escaping the quotes matters most inside attributes: an unescaped " would end the value attribute and let an attacker add their own attributes or tags. Since PHP 8.1, htmlspecialchars() escapes both double and single quotes by default.
Because you call it so often, most projects define a short helper:
function e(string $text): string
{
return htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
}A complete validated form
This library membership form brings everything together. Errors are stored in an array keyed by field name, so each message can appear next to its own field:
<?php
declare(strict_types=1);
function e(string $text): string
{
return htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
}
function field(string $key): string
{
$value = $_POST[$key] ?? '';
return is_string($value) ? trim($value) : '';
}
$labels = ['name' => 'Name', 'email' => 'Email', 'mobile' => 'Mobile', 'age' => 'Age'];
$values = array_fill_keys(array_keys($labels), '');
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
foreach (array_keys($values) as $key) {
$values[$key] = field($key);
}
if ($values['name'] === '') {
$errors['name'] = 'Name is required.';
} elseif (mb_strlen($values['name']) > 60) {
$errors['name'] = 'Name must be 60 characters or fewer.';
}
if (filter_var($values['email'], FILTER_VALIDATE_EMAIL) === false) {
$errors['email'] = 'Enter a valid email address.';
}
if (preg_match('/^[6-9][0-9]{9}$/', $values['mobile']) !== 1) {
$errors['mobile'] = 'Enter a 10-digit mobile number.';
}
$age = filter_var($values['age'], FILTER_VALIDATE_INT, [
'options' => ['min_range' => 16, 'max_range' => 60],
]);
if ($age === false) {
$errors['age'] = 'Age must be a whole number from 16 to 60.';
}
if ($errors === []) {
// Save the member here, then redirect (Post/Redirect/Get).
header('Location: join.php?done=1', true, 303);
exit;
}
}
?>
<form method="post" action="join.php">
<?php foreach ($labels as $key => $label): ?>
<p>
<label for="<?= $key ?>"><?= $label ?></label>
<input id="<?= $key ?>" name="<?= $key ?>" value="<?= e($values[$key]) ?>">
<?php if (isset($errors[$key])): ?>
<span class="error"><?= e($errors[$key]) ?></span>
<?php endif; ?>
</p>
<?php endforeach; ?>
<button>Join the library</button>
</form>Suppose Isha submits the name Isha "Izzy" Rao, the email isha@mail, the mobile 98765 and the age 15. The page comes back with her values kept and an error beside each wrong field:
<form method="post" action="join.php">
<p>
<label for="name">Name</label>
<input id="name" name="name" value="Isha "Izzy" Rao">
</p>
<p>
<label for="email">Email</label>
<input id="email" name="email" value="isha@mail">
<span class="error">Enter a valid email address.</span>
</p>
<p>
<label for="mobile">Mobile</label>
<input id="mobile" name="mobile" value="98765">
<span class="error">Enter a 10-digit mobile number.</span>
</p>
<p>
<label for="age">Age</label>
<input id="age" name="age" value="15">
<span class="error">Age must be a whole number from 16 to 60.</span>
</p>
<button>Join the library</button>
</form>Her name passed validation, and the quotes in it are escaped inside the value attribute, so the box shows Isha "Izzy" Rao correctly. She only has to fix three fields, not retype everything. In a real form, also add HTML attributes such as required and type="email" for instant feedback, and a CSRF token as described in PHP Security Essentials.
Common mistakes
- Relying on HTML validation alone. It is easily bypassed. Always validate in PHP.
- Checking filter results with
!.filter_var('0', FILTER_VALIDATE_INT)returns0, which is falsy but valid. Compare with=== false. - Escaping when saving instead of when printing. Store raw values; escape at output time for the place you print to.
- Forgetting to escape inside attributes.
value="<?= $name ?>"breaks as soon as a name contains a quote. - Using
strlen()for length limits. Hindi names hit the limit three times too early. Usemb_strlen(). - Writing regex patterns without
^and$. The pattern then matches part of the value instead of the whole.
Key points
- Client-side validation is for convenience; server-side validation in PHP is required.
- Validate input when it arrives; escape output when you print it.
- Make sure each value is a string,
trim()it, then check required fields and lengths withmb_strlen(). filter_var()returns the clean value orfalse;filter_input()also returnsnullfor a missing field.- Use
preg_match()with anchored patterns for formats like mobile numbers and PIN codes. - Escape every user value with
htmlspecialchars()before putting it into HTML, especially inside attributes.
Exercise
Write a function validateBooking(array $input): array for a homestay in Jaipur. It receives an array like $_POST with the keys guest, email, guests and pin. It returns an array of error messages keyed by field: guest is required and at most 50 characters; email must be a valid email; guests must be a whole number from 1 to 6; pin must be a 6-digit Indian PIN code. Test it with one valid booking and one invalid booking, and print the errors with print_r().
Show solution
<?php
declare(strict_types=1);
function validateBooking(array $input): array
{
$get = fn(string $key): string => is_string($input[$key] ?? null) ? trim($input[$key]) : '';
$errors = [];
$guest = $get('guest');
if ($guest === '' || mb_strlen($guest) > 50) {
$errors['guest'] = 'Enter a name of up to 50 characters.';
}
if (filter_var($get('email'), FILTER_VALIDATE_EMAIL) === false) {
$errors['email'] = 'Enter a valid email address.';
}
$range = ['options' => ['min_range' => 1, 'max_range' => 6]];
if (filter_var($get('guests'), FILTER_VALIDATE_INT, $range) === false) {
$errors['guests'] = 'Guests must be from 1 to 6.';
}
if (preg_match('/^[1-9][0-9]{5}$/', $get('pin')) !== 1) {
$errors['pin'] = 'Enter a 6-digit PIN code.';
}
return $errors;
}
print_r(validateBooking(['guest' => 'Farhan', 'email' => 'farhan@example.in', 'guests' => '3', 'pin' => '302001']));
print_r(validateBooking(['guest' => ' ', 'email' => 'farhan@', 'guests' => '9', 'pin' => '30200']));Array
(
)
Array
(
[guest] => Enter a name of up to 50 characters.
[email] => Enter a valid email address.
[guests] => Guests must be from 1 to 6.
[pin] => Enter a 6-digit PIN code.
)An empty array means the booking is valid. Because the function takes an array instead of reading $_POST directly, you can test it from the terminal with any values you like.
Quick Quiz
1. Why must you validate form data in PHP even when the HTML form uses required?
Browser checks are a convenience; requests can be sent without your form, so the server must check again.
2. What does filter_var('abc', FILTER_VALIDATE_INT) return?
filter_var() returns false when the value fails the filter.
3. What does filter_input(INPUT_POST, 'age', FILTER_VALIDATE_INT) return when the form did not send an age field?
filter_input() returns null for a missing field and false for an invalid one.
4. When should you call htmlspecialchars() on a visitor's name?
Store the original value and escape it at output time, every time it goes into HTML.
Learn PHP with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
