PHP Form Validation

Form validation is the process of checking that data submitted through an HTML form actually meets the rules your application expects — required fields are filled in, an email address looks like an email address, a number falls inside a sensible range — before you trust it, store it, or act on it. Because anyone can send any data to your PHP script, not just people typing into your form but bots, scripts, and attackers using tools like curl, validating on the server is not optional. Client-side JavaScript can make a form feel nicer to use, but PHP form validation is what actually protects your application, since it runs on the one copy of the data you cannot control the origin of.

Overview: How PHP Form Validation Works

When a browser submits a form, PHP parses the request body (or the query string, for a GET form) into the $_POST or $_GET superglobal array before your script even starts running. Every value in those arrays begins life as a string, or an array of strings for fields like name="tags[]", no matter what an <input type="number"> or <input type="email"> tag suggested to the browser. HTML attributes such as required, type="email", and pattern only run inside the browser — they are trivial to bypass by editing the page, disabling JavaScript, or sending a raw HTTP request — so PHP must independently re-check everything once the data arrives on the server.

The standard pattern is: read each field out of the superglobal, trim it, run it through one or more checks, and collect any problems into an $errors array keyed by field name. If $errors ends up empty after all checks, the submission is valid and you can safely use, store, or process the data. If it isn’t empty, you redisplay the form together with the error messages and the values the user already typed, so they don’t have to start over. This pattern scales the same way whether you’re checking one field or fifty, and it keeps the “is this data okay?” logic cleanly separate from the “what do I do once it’s okay?” logic.

It’s worth being precise about a distinction beginners often blur: validation is not the same thing as sanitization or escaping. Validation asks “is this data acceptable?” and accepts or rejects the whole submission. Escaping changes how a value is *displayed or embedded* so it’s safe for a particular context — for example, running htmlspecialchars() on user input before echoing it back into an HTML page, or using a prepared statement placeholder before a value reaches SQL. A field can pass validation perfectly and still need escaping when it’s shown on a page, because validation guards against bad data while escaping guards against bad output contexts.

Syntax

Most field validations follow the same shape: read, trim, check, collect.

<?php
$errors = [];
$value  = trim($_POST['field_name'] ?? '');

if ($value === '') {
    $errors['field_name'] = 'This field is required.';
} elseif (/* some other check fails */ false) {
    $errors['field_name'] = 'This field is invalid.';
}

if (empty($errors)) {
    // process the validated data
} else {
    // redisplay the form with $errors and the submitted values
}
  • $_POST['field_name'] ?? '' — reads the field safely; the null coalescing operator avoids an “undefined array key” warning when the field was never submitted at all.
  • trim() — strips leading/trailing whitespace so a field containing only spaces doesn’t slip past a required check.
  • $errors — an associative array keyed by field name, so each field can carry its own message and you can look up $errors['email'] when redisplaying the form next to that specific input.
  • empty($errors) — the single gate that decides whether the submission as a whole is valid; nothing runs (no database insert, no redirect) unless this is true.

Examples

Example 1: Required Fields and Email Format

The simplest and most common case: a name that must not be blank, and an email address that must look like a real email address.

<?php
$errors = [];
$name = trim($_POST['name'] ?? '');
$email = trim($_POST['email'] ?? '');

if ($name === '') {
    $errors['name'] = 'Name is required.';
}

if ($email === '') {
    $errors['email'] = 'Email is required.';
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
    $errors['email'] = 'Email is not valid.';
}

if (empty($errors)) {
    echo 'Form submitted successfully!';
} else {
    foreach ($errors as $field => $message) {
        echo "$field: $message" . PHP_EOL;
    }
}

Output:

name: Name is required.
email: Email is required.

Because no form was actually submitted here, both $_POST['name'] and $_POST['email'] are missing, so ?? '' falls back to an empty string for each, and both required checks fail. Note the elseif on the email check: it only bothers running filter_var() once it already knows the field isn’t blank, avoiding a confusing “invalid email” message on top of the “required” one.

Example 2: Validating a Numeric Range with filter_var and match

Numbers need their own care: a user can type letters into a text box meant for an age, or a number outside any reasonable range. filter_var() with FILTER_VALIDATE_INT and a min_range/max_range option handles both problems in one call.

<?php
function validateAge(mixed $ageInput): string
{
    $options = ['options' => ['min_range' => 0, 'max_range' => 120]];
    $age = filter_var($ageInput, FILTER_VALIDATE_INT, $options);

    if ($age === false) {
        return 'Age must be a whole number between 0 and 120.';
    }

    $category = match (true) {
        $age < 13 => 'child',
        $age < 20 => 'teen',
        $age < 65 => 'adult',
        default => 'senior',
    };

    return "Valid age: {$age} ({$category})";
}

echo validateAge('34') . PHP_EOL;
echo validateAge('abc') . PHP_EOL;
echo validateAge('150') . PHP_EOL;

Output:

Valid age: 34 (adult)
Age must be a whole number between 0 and 120.
Age must be a whole number between 0 and 120.

filter_var() returns the filtered value (an int here) on success, or the boolean false on failure — that’s why the check is a strict === false rather than a truthiness check, since a valid age of 0 would otherwise be mistaken for failure. The match (true) expression then buckets the already-validated integer into a category using its first matching arm.

Example 3: A Multi-Field Registration Validator

Real forms usually validate several fields at once and report every problem together, not just the first one found, so the user can fix everything in one pass.

<?php
$errors = [];
$data = [
    'username' => trim($_POST['username'] ?? ''),
    'email'    => trim($_POST['email'] ?? ''),
    'age'      => trim($_POST['age'] ?? ''),
];

if (!preg_match('/^[a-zA-Z0-9_]{3,16}$/', $data['username'])) {
    $errors['username'] = 'Username must be 3-16 characters (letters, numbers, underscore only).';
}

if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
    $errors['email'] = 'Please enter a valid email address.';
}

if ($data['age'] === '' || !ctype_digit($data['age'])) {
    $errors['age'] = 'Age must be a positive whole number.';
}

if (empty($errors)) {
    echo 'Registration accepted for ' . htmlspecialchars($data['username'], ENT_QUOTES);
} else {
    echo 'Found ' . count($errors) . ' error(s):' . PHP_EOL;
    foreach ($errors as $field => $message) {
        echo "- {$field}: {$message}" . PHP_EOL;
    }
}

Output:

Found 3 error(s):
- username: Username must be 3-16 characters (letters, numbers, underscore only).
- email: Please enter a valid email address.
- age: Age must be a positive whole number.

All three fields are empty strings by default, so all three checks fail and $errors collects three separate messages, each one keyed by field name so a real template could print the right message next to the right input. Notice ctype_digit() is used instead of is_numeric() for the age — is_numeric() would accept "3.5" or "-4", which aren’t valid whole ages, while ctype_digit() only accepts strings made entirely of the digits 0-9.

How It Works Step by Step

  1. The browser sends an HTTP request whose body (POST) or query string (GET) contains the form fields as URL-encoded key/value pairs.
  2. Before your script runs, the SAPI (the layer connecting the web server to the PHP engine) parses that body and populates $_POST or $_GET as plain associative arrays of strings.
  3. Your script reads each field defensively with ??, since a field that was never rendered, never checked (for checkboxes), or stripped by a proxy simply won’t exist in the array.
  4. trim() removes incidental whitespace so a field of only spaces doesn’t pass a naive truthiness check.
  5. Type- or format-specific rules run: filter_var() for well-known formats (email, URL, int, float) using PHP’s built-in validation filters, or preg_match() against a PCRE regular expression for custom formats like usernames or product codes.
  6. Every failure is appended to $errors keyed by field name, so validation keeps checking the rest of the fields instead of stopping at the first problem.
  7. A single empty($errors) check at the end decides the branch: process and persist the data, or redisplay the form with error messages and the previously submitted (escaped) values.

Common Mistakes

Mistake 1: Using empty() to check for a required value

empty() treats the string "0", the number 0, and false all as “empty” — which is wrong when zero is a legitimate value, such as a quantity field where 0 is a valid (if unusual) entry.

<?php
$_POST['quantity'] = '0';
$quantity = $_POST['quantity'] ?? '';
$errors = [];

if (empty($quantity)) {
    $errors[] = 'Quantity is required.';
}

echo empty($errors) ? 'Valid' : 'Invalid: ' . $errors[0];

Here the user legitimately typed 0, but empty('0') evaluates to true, so the field is incorrectly flagged as missing. The fix is to check for the empty string explicitly with strict comparison, which only rejects a field that is truly blank:

<?php
$_POST['quantity'] = '0';
$quantity = $_POST['quantity'] ?? '';
$errors = [];

if (trim($quantity) === '') {
    $errors[] = 'Quantity is required.';
}

echo empty($errors) ? 'Valid' : 'Invalid: ' . $errors[0];

Now "0" is accepted as a valid, present value, and only a genuinely blank submission produces the error.

Mistake 2: Echoing submitted values back without escaping

When redisplaying a sticky form, it’s tempting to drop the submitted value straight into the HTML attribute. If the value ever contains a quote or an angle bracket — whether by accident or because someone is deliberately attacking the form — this breaks the page or injects script.

<?php
$name = $_POST['name'] ?? '';
echo '<input type="text" name="name" value="' . $name . '">';

If $name ever contained "><script>alert(1)</script>, this code would close the attribute early and inject a script tag straight into the page — a classic stored/reflected XSS bug. The fix is to always pass user-controlled data through htmlspecialchars() at the point it’s written into HTML, regardless of whether it already passed validation:

<?php
$name = $_POST['name'] ?? '';
echo '<input type="text" name="name" value="' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '">';

Validation and escaping are two separate defenses; a value can be validated as “a syntactically fine name” and still need escaping the moment it’s placed inside HTML, an attribute, or a URL.

Best Practices

  • Always validate on the server, even if you also validate in JavaScript — client-side checks are a UX nicety, not a security boundary.
  • trim() every text field before checking it, so whitespace-only input doesn’t slip past a required check.
  • For required-string checks, compare with === '' rather than empty(), so values like "0" aren’t wrongly rejected.
  • Use filter_var() with the built-in filters (FILTER_VALIDATE_EMAIL, FILTER_VALIDATE_INT, FILTER_VALIDATE_FLOAT, FILTER_VALIDATE_URL) for well-known formats instead of hand-rolled regular expressions.
  • Use preg_match() with an anchored pattern (^$) for custom formats like usernames, product codes, or postal codes.
  • Collect every error into one $errors array keyed by field name instead of stopping at the first failure, so users see all the problems at once.
  • Make forms sticky: redisplay the submitted values (escaped with htmlspecialchars()) so users never have to retype a whole form because of one mistake.
  • Never trust an uploaded file’s declared MIME type or extension from $_FILES alone — validate size, extension, and actual content type server-side.
  • Escape output at the point of use, not at the point of input — validating data doesn’t make it safe to print without htmlspecialchars().
  • Prefer failing closed: if a rule can’t confidently confirm a value is valid, reject it rather than guessing it’s probably fine.

Practice Exercises

  1. Write a validator for a “contact us” form with name, email, and message fields. message must be at least 10 characters after trimming. Collect all errors into an array and print them one per line.
  2. Write a function validatePassword(string $password): array that returns a list of error messages for a password that is shorter than 8 characters, has no digit, or has no uppercase letter. It should return an empty array when the password satisfies all three rules.
  3. Write a validator for a phone field using preg_match() that only accepts exactly 10 digits (no spaces, dashes, or parentheses). Test it against "5551234567", "555-123-4567", and "12345", and predict which ones pass before you check.

Summary

  • Form data always arrives as strings in $_POST/$_GET, regardless of what the HTML input type suggested to the browser.
  • Client-side validation is a convenience; server-side validation in PHP is the actual security and data-integrity boundary.
  • The standard pattern is: read with ??, trim(), check, collect failures into an $errors array keyed by field name, then branch on empty($errors).
  • filter_var() handles well-known formats (email, int, float, URL); preg_match() handles custom formats via regular expressions.
  • Use strict, explicit comparisons (=== '', === false) instead of loose truthiness checks like bare empty(), which misclassify values like "0".
  • Validation and escaping are different concerns — always run htmlspecialchars() on user data before it’s written into HTML, even after it has passed validation.