Skip to main content

🛡️ Lesson 12: Input Validation & Sanitization

Never trust user input. Every piece of data that comes from a form, URL, cookie, or API must be validated (is it what we expect?) and sanitized (is it safe to use?). This lesson teaches you PHP's built-in tools for both — so your applications reject bad data before it causes problems.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain the difference between validation and sanitization
  • Use filter_var() with FILTER_VALIDATE_* constants to validate emails, URLs, integers, and more
  • Use filter_var() with FILTER_SANITIZE_* constants to clean user input
  • Use filter_input() to read and filter GET/POST data in one step
  • Build custom validation rules with regular expressions
  • Implement a reusable validation pattern with user-friendly error messages

Estimated Time: 45 minutes

Prerequisites: Lesson 11 (forms, $_GET, $_POST)

📑 In This Lesson

Why Validate? The Trust Boundary

In Lesson 11, we learned to read form data with $_GET and $_POST. But we only added basic checks like "is the field empty?" Real applications face a much bigger problem: users (and attackers) can send absolutely anything.

Consider this: your form has a number field for "age" with min="18" max="120". HTML5 prevents the browser from submitting invalid values, right? Wrong. Anyone can:

  • Edit the HTML with browser developer tools and remove the constraints
  • Submit the form using curl or Postman without any browser at all
  • Send age=-9999 or age=<script>alert('hacked')</script>

⚠️ The Golden Rule of Web Security

Client-side validation is a convenience for the user. Server-side validation is a requirement for security. HTML5 validation attributes, JavaScript checks, and browser constraints can all be bypassed. PHP must be the final gatekeeper.

flowchart LR A[User Input] --> B{Client-Side Validation} B -->|Can be bypassed| C{Server-Side Validation} C -->|Valid| D[Process Data] C -->|Invalid| E[Return Errors] style B fill:#fef3c7,stroke:#f59e0b style C fill:#dcfce7,stroke:#22c55e

What Can Go Wrong?

Without proper validation and sanitization, your application is vulnerable to:

Attack What Happens Defense
SQL Injection Attacker manipulates database queries Prepared statements (Lesson 19)
XSS (Cross-Site Scripting) Attacker injects JavaScript into your pages htmlspecialchars() on all output
Data corruption Invalid data breaks your logic or database Type validation, range checks
Email header injection Attacker sends spam through your contact form Email validation, newline filtering
Path traversal Attacker accesses files outside allowed directories Filename validation, basename()

Validation and sanitization are your first line of defense. We'll revisit security in depth in Lesson 23, but learning these habits now prevents problems later.

Validation vs. Sanitization

These two concepts are related but distinct, and you'll often use both together.

📖 Definitions

Validation: Checking whether input meets your requirements. Does this look like an email? Is this number in the allowed range? Is this field filled in? Validation answers yes or no — it doesn't change the data.

Sanitization: Cleaning input to make it safe. Stripping HTML tags, removing illegal characters, encoding special characters. Sanitization modifies the data to remove anything dangerous.

<?php
$email = " Alice@Example.COM  ";

// Sanitization — clean it up
$email = trim($email);                                    // "Alice@Example.COM"
$email = filter_var($email, FILTER_SANITIZE_EMAIL);       // "Alice@Example.COM"
$email = strtolower($email);                              // "alice@example.com"

// Validation — is it actually valid?
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
    echo "Valid email: $email";
} else {
    echo "Invalid email!";
}

The Recommended Order

flowchart LR A[Raw Input] --> B["1. Trim whitespace"] B --> C["2. Sanitize (clean)"] C --> D["3. Validate (check)"] D -->|Pass| E[Use the data] D -->|Fail| F[Show error message]
  1. Trim — Remove leading/trailing whitespace with trim()
  2. Sanitize — Remove or encode unwanted characters
  3. Validate — Check that the cleaned data meets your rules

✅ Think of It Like This

Sanitization is like washing vegetables — remove the dirt. Validation is like checking the expiration date — is it still good to use? You wash first, then check.

filter_var() — Validation Filters

filter_var() is PHP's Swiss Army knife for input filtering. It takes a value and a filter constant, and either returns the filtered value or false on failure.

<?php
// Basic syntax
$result = filter_var($value, FILTER_CONSTANT);
// Returns the value if valid, or false if invalid

FILTER_VALIDATE_EMAIL

<?php
// Validates email format (follows RFC 822)
var_dump(filter_var("alice@example.com", FILTER_VALIDATE_EMAIL));
// string "alice@example.com"

var_dump(filter_var("not-an-email", FILTER_VALIDATE_EMAIL));
// false

var_dump(filter_var("alice@.com", FILTER_VALIDATE_EMAIL));
// false

var_dump(filter_var("alice+work@example.co.uk", FILTER_VALIDATE_EMAIL));
// string "alice+work@example.co.uk" (valid!)

// Practical usage
$email = trim($_POST["email"] ?? "");
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
    $errors[] = "Please enter a valid email address.";
}

✅ Always Use FILTER_VALIDATE_EMAIL

Don't write your own email regex — it's nearly impossible to cover the full email spec. PHP's built-in filter handles edge cases like plus addressing (user+tag@domain.com), subdomains, and international TLDs correctly.

FILTER_VALIDATE_INT

<?php
// Validates that a value is an integer
var_dump(filter_var("42", FILTER_VALIDATE_INT));      // int 42
var_dump(filter_var("0", FILTER_VALIDATE_INT));        // int 0
var_dump(filter_var("-7", FILTER_VALIDATE_INT));        // int -7
var_dump(filter_var("3.14", FILTER_VALIDATE_INT));     // false (float!)
var_dump(filter_var("42abc", FILTER_VALIDATE_INT));    // false
var_dump(filter_var("", FILTER_VALIDATE_INT));          // false

// With range options
$age = filter_var($_POST["age"] ?? "", FILTER_VALIDATE_INT, [
    "options" => [
        "min_range" => 13,
        "max_range" => 120,
    ]
]);

if ($age === false) {
    $errors[] = "Age must be a whole number between 13 and 120.";
}

⚠️ Zero Is Valid! Use Strict Comparison

filter_var("0", FILTER_VALIDATE_INT) returns 0, which is falsy. Always compare with === false:

// BAD — thinks 0 is invalid!
if (!filter_var("0", FILTER_VALIDATE_INT)) { ... }

// GOOD — strict check for false
if (filter_var("0", FILTER_VALIDATE_INT) === false) { ... }

FILTER_VALIDATE_FLOAT

<?php
var_dump(filter_var("3.14", FILTER_VALIDATE_FLOAT));     // float 3.14
var_dump(filter_var("42", FILTER_VALIDATE_FLOAT));        // float 42.0
var_dump(filter_var("-0.5", FILTER_VALIDATE_FLOAT));      // float -0.5
var_dump(filter_var("1,234.56", FILTER_VALIDATE_FLOAT));  // false (comma!)

// Allow thousand separator
$price = filter_var("1,234.56", FILTER_VALIDATE_FLOAT, [
    "flags" => FILTER_FLAG_ALLOW_THOUSAND
]);
// float 1234.56

FILTER_VALIDATE_URL

<?php
var_dump(filter_var("https://example.com", FILTER_VALIDATE_URL));
// string "https://example.com"

var_dump(filter_var("ftp://files.example.com/doc", FILTER_VALIDATE_URL));
// string "ftp://files.example.com/doc"

var_dump(filter_var("not a url", FILTER_VALIDATE_URL));
// false

var_dump(filter_var("example.com", FILTER_VALIDATE_URL));
// false (needs scheme: https://example.com)

// Require specific components
$url = filter_var($input, FILTER_VALIDATE_URL);
if ($url === false) {
    $errors[] = "Please enter a valid URL (include https://).";
}

FILTER_VALIDATE_IP

<?php
var_dump(filter_var("192.168.1.1", FILTER_VALIDATE_IP));       // valid IPv4
var_dump(filter_var("::1", FILTER_VALIDATE_IP));                // valid IPv6
var_dump(filter_var("999.999.999.999", FILTER_VALIDATE_IP));   // false

// Validate only IPv4
filter_var($ip, FILTER_VALIDATE_IP, ["flags" => FILTER_FLAG_IPV4]);

// Validate only IPv6
filter_var($ip, FILTER_VALIDATE_IP, ["flags" => FILTER_FLAG_IPV6]);

// Reject private/reserved ranges
filter_var($ip, FILTER_VALIDATE_IP, ["flags" => FILTER_FLAG_NO_PRIV_RANGE]);

FILTER_VALIDATE_BOOLEAN

<?php
// Returns true, false, or null (if not a boolean-like value)
var_dump(filter_var("true", FILTER_VALIDATE_BOOLEAN));    // true
var_dump(filter_var("1", FILTER_VALIDATE_BOOLEAN));        // true
var_dump(filter_var("yes", FILTER_VALIDATE_BOOLEAN));      // true
var_dump(filter_var("on", FILTER_VALIDATE_BOOLEAN));       // true

var_dump(filter_var("false", FILTER_VALIDATE_BOOLEAN));    // false
var_dump(filter_var("0", FILTER_VALIDATE_BOOLEAN));        // false
var_dump(filter_var("no", FILTER_VALIDATE_BOOLEAN));       // false
var_dump(filter_var("off", FILTER_VALIDATE_BOOLEAN));      // false

// With FILTER_NULL_ON_FAILURE — returns null instead of false for non-booleans
var_dump(filter_var("maybe", FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE));
// null

FILTER_VALIDATE_DOMAIN

<?php
// PHP 7+ — validates domain names
var_dump(filter_var("example.com", FILTER_VALIDATE_DOMAIN));      // "example.com"
var_dump(filter_var("sub.example.co.uk", FILTER_VALIDATE_DOMAIN)); // valid
var_dump(filter_var("-invalid.com", FILTER_VALIDATE_DOMAIN));      // false
var_dump(filter_var("no spaces.com", FILTER_VALIDATE_DOMAIN));     // false

Quick Reference: Validation Filters

Filter Validates Returns on Success
FILTER_VALIDATE_EMAIL Email address format The email string
FILTER_VALIDATE_INT Integer (optional min/max range) The integer value
FILTER_VALIDATE_FLOAT Floating-point number The float value
FILTER_VALIDATE_URL URL with scheme The URL string
FILTER_VALIDATE_IP IPv4 or IPv6 address The IP string
FILTER_VALIDATE_BOOLEAN Boolean-like values true or false
FILTER_VALIDATE_DOMAIN Domain name The domain string
FILTER_VALIDATE_MAC MAC address The MAC string

filter_var() — Sanitization Filters

Sanitization filters modify the input, stripping or encoding characters that could cause problems. Unlike validation, they always return a string — they don't return false.

FILTER_SANITIZE_EMAIL

<?php
// Removes characters not allowed in emails
echo filter_var("alice@example.com", FILTER_SANITIZE_EMAIL);
// "alice@example.com" (unchanged)

echo filter_var("alice @example .com", FILTER_SANITIZE_EMAIL);
// "alice@example.com" (spaces removed)

echo filter_var("alice', FILTER_SANITIZE_SPECIAL_CHARS);
// "&#60;script&#62;alert(&#34;xss&#34;)&#60;/script&#62;"

FILTER_SANITIZE_ADD_SLASHES

<?php
// Adds backslashes before quotes and backslash (like addslashes())
echo filter_var('He said "hello"', FILTER_SANITIZE_ADD_SLASHES);
// "He said \"hello\""

htmlspecialchars() — The Essential Sanitizer

While filter_var has sanitization filters, htmlspecialchars() remains the most important function for output sanitization. Use it every time you display user data in HTML:

<?php
$userInput = '<script>alert("hacked")</script>';

// What htmlspecialchars converts:
// <  → &lt;
// >  → &gt;
// "  → &quot;
// '  → &#039;  (with ENT_QUOTES)
// &  → &amp;

echo htmlspecialchars($userInput, ENT_QUOTES, "UTF-8");
// &lt;script&gt;alert(&quot;hacked&quot;)&lt;/script&gt;
// Browser displays it as text, not as executable code

📖 Two Contexts, Two Functions

Input sanitization — Use filter_var() with FILTER_SANITIZE_* to clean data when you receive it.

Output sanitization — Use htmlspecialchars() when displaying data in HTML. This is your primary XSS defense.

Many developers do both: sanitize on input AND escape on output. That's the safest approach.

Sanitize + Validate Together

The typical pattern is to sanitize first, then validate the cleaned result:

<?php
// Step 1: Get and trim the input
$email = trim($_POST["email"] ?? "");

// Step 2: Sanitize — remove illegal email characters
$email = filter_var($email, FILTER_SANITIZE_EMAIL);

// Step 3: Validate — is it a properly formatted email?
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
    $errors[] = "Please enter a valid email address.";
}

// Step 4: When displaying, always escape
echo "Email: " . htmlspecialchars($email);

filter_input() — Direct from GET/POST

filter_input() reads a value directly from $_GET, $_POST, or other input sources and applies a filter in one step. It returns null if the key doesn't exist, false if validation fails, or the filtered value on success.

<?php
// Syntax: filter_input(INPUT_TYPE, "key", FILTER, options)

// Read and validate an email from POST
$email = filter_input(INPUT_POST, "email", FILTER_VALIDATE_EMAIL);

if ($email === null) {
    echo "Email field was not submitted.";
} elseif ($email === false) {
    echo "Invalid email format.";
} else {
    echo "Valid email: $email";
}

// Read and validate an integer from GET
$page = filter_input(INPUT_GET, "page", FILTER_VALIDATE_INT, [
    "options" => ["min_range" => 1, "default" => 1]
]);

// Read and sanitize a string from POST
$name = filter_input(INPUT_POST, "name", FILTER_SANITIZE_SPECIAL_CHARS);

Input Type Constants

Constant Reads From
INPUT_GET $_GET
INPUT_POST $_POST
INPUT_COOKIE $_COOKIE
INPUT_SERVER $_SERVER
INPUT_ENV $_ENV

filter_input vs. filter_var

<?php
// These two approaches are equivalent:

// Approach 1: filter_var (two steps)
$email = $_POST["email"] ?? "";
$email = filter_var($email, FILTER_VALIDATE_EMAIL);

// Approach 2: filter_input (one step)
$email = filter_input(INPUT_POST, "email", FILTER_VALIDATE_EMAIL);

// filter_input is slightly cleaner for simple cases.
// filter_var is more flexible — you can sanitize THEN validate.

✅ When to Use Which

Use filter_input() for quick, single-step validation of GET/POST data — especially for simple fields like page numbers, IDs, and email addresses.

Use filter_var() when you need to sanitize first and then validate, or when you're working with data that's already in a variable (not directly from a superglobal).

filter_input_array() — Validate Multiple Fields at Once

<?php
// Define filters for multiple POST fields at once
$filters = [
    "name"  => FILTER_SANITIZE_SPECIAL_CHARS,
    "email" => FILTER_VALIDATE_EMAIL,
    "age"   => [
        "filter"  => FILTER_VALIDATE_INT,
        "options" => ["min_range" => 13, "max_range" => 120],
    ],
    "url"   => FILTER_VALIDATE_URL,
];

// Apply all filters in one call
$result = filter_input_array(INPUT_POST, $filters);

// $result is an associative array:
// [
//     "name"  => "Alice" (sanitized),
//     "email" => "alice@example.com" or false,
//     "age"   => 25 or false,
//     "url"   => "https://..." or false,
// ]

// Check each result
if ($result["email"] === false) {
    $errors[] = "Invalid email address.";
}
if ($result["age"] === false) {
    $errors[] = "Age must be between 13 and 120.";
}

Custom Validation with Regex

PHP's built-in filters cover common cases, but sometimes you need custom patterns. That's where regular expressions from Lesson 10 come back into play.

Common Custom Validations

Username (alphanumeric + underscore, 3–20 chars)

<?php
function validate_username(string $username): bool {
    return (bool) preg_match('/^[a-zA-Z0-9_]{3,20}$/', $username);
}

// Test
var_dump(validate_username("alice_123"));    // true
var_dump(validate_username("ab"));           // false (too short)
var_dump(validate_username("alice smith"));  // false (space)
var_dump(validate_username("alice@bob"));    // false (@ not allowed)

Phone Number (US format)

<?php
function validate_phone(string $phone): bool {
    // Accept: 555-123-4567, (555) 123-4567, 5551234567, 555.123.4567
    $cleaned = preg_replace('/[\s\-\.\(\)]/', '', $phone);
    return (bool) preg_match('/^1?\d{10}$/', $cleaned);
}

function format_phone(string $phone): string {
    $digits = preg_replace('/\D/', '', $phone);
    if (strlen($digits) === 11 && $digits[0] === "1") {
        $digits = substr($digits, 1); // Remove leading 1
    }
    return sprintf("(%s) %s-%s",
        substr($digits, 0, 3),
        substr($digits, 3, 3),
        substr($digits, 6, 4)
    );
}

echo format_phone("2025551234"); // "(202) 555-1234"

US ZIP Code

<?php
function validate_zip(string $zip): bool {
    return (bool) preg_match('/^\d{5}(-\d{4})?$/', $zip);
}

var_dump(validate_zip("89012"));      // true
var_dump(validate_zip("89012-1234")); // true
var_dump(validate_zip("8901"));       // false
var_dump(validate_zip("ABCDE"));      // false

Strong Password

<?php
function validate_password(string $password): array {
    $errors = [];

    if (strlen($password) < 8) {
        $errors[] = "Must be at least 8 characters.";
    }
    if (!preg_match('/[A-Z]/', $password)) {
        $errors[] = "Must contain at least one uppercase letter.";
    }
    if (!preg_match('/[a-z]/', $password)) {
        $errors[] = "Must contain at least one lowercase letter.";
    }
    if (!preg_match('/\d/', $password)) {
        $errors[] = "Must contain at least one digit.";
    }
    if (!preg_match('/[^a-zA-Z\d]/', $password)) {
        $errors[] = "Must contain at least one special character.";
    }

    return $errors;
}

$pwErrors = validate_password("abc");
// ["Must be at least 8 characters.", "Must contain at least one uppercase letter.",
//  "Must contain at least one digit.", "Must contain at least one special character."]

$pwErrors = validate_password("Str0ng!Pass");
// [] (empty — all checks passed)

Hex Color Code

<?php
function validate_hex_color(string $color): bool {
    return (bool) preg_match('/^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $color);
}

var_dump(validate_hex_color("#FF00AA"));  // true
var_dump(validate_hex_color("#F0A"));     // true (shorthand)
var_dump(validate_hex_color("FF00AA"));   // true (without #)
var_dump(validate_hex_color("#GGHHII"));  // false

Using FILTER_VALIDATE_REGEXP

You can also use custom regex directly with filter_var():

<?php
// Validate a product code like "PRD-12345"
$code = "PRD-12345";
$result = filter_var($code, FILTER_VALIDATE_REGEXP, [
    "options" => ["regexp" => '/^[A-Z]{3}-\d{5}$/']
]);

if ($result === false) {
    echo "Invalid product code format.";
} else {
    echo "Valid code: $result";
}

✅ Write Specific Error Messages

Instead of "Invalid input," tell the user exactly what's wrong: "Username must be 3–20 characters using only letters, numbers, and underscores." Specific messages help users fix their input without guessing.

Building a Validation Pattern

Let's put everything together into a reusable pattern for validating forms. This is a structure you'll use in virtually every PHP project.

Complete Self-Processing Form with Validation

<?php
// ============================================
// 1. Initialize variables and error array
// ============================================
$name     = "";
$email    = "";
$age      = "";
$website  = "";
$password = "";
$errors   = [];
$success  = false;

// ============================================
// 2. Process form submission
// ============================================
if ($_SERVER["REQUEST_METHOD"] === "POST") {

    // --- Name ---
    $name = trim($_POST["name"] ?? "");
    if ($name === "") {
        $errors["name"] = "Name is required.";
    } elseif (strlen($name) < 2 || strlen($name) > 50) {
        $errors["name"] = "Name must be 2–50 characters.";
    } elseif (!preg_match('/^[a-zA-Z\s\'-]+$/', $name)) {
        $errors["name"] = "Name can only contain letters, spaces, hyphens, and apostrophes.";
    }

    // --- Email ---
    $email = trim($_POST["email"] ?? "");
    $email = filter_var($email, FILTER_SANITIZE_EMAIL);
    if ($email === "") {
        $errors["email"] = "Email is required.";
    } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $errors["email"] = "Please enter a valid email address.";
    }

    // --- Age ---
    $age = $_POST["age"] ?? "";
    $ageValid = filter_var($age, FILTER_VALIDATE_INT, [
        "options" => ["min_range" => 13, "max_range" => 120]
    ]);
    if ($age === "") {
        $errors["age"] = "Age is required.";
    } elseif ($ageValid === false) {
        $errors["age"] = "Age must be a number between 13 and 120.";
    } else {
        $age = $ageValid; // Use the validated integer
    }

    // --- Website (optional) ---
    $website = trim($_POST["website"] ?? "");
    if ($website !== "") {
        // Add https:// if no scheme provided
        if (!preg_match('/^https?:\/\//', $website)) {
            $website = "https://" . $website;
        }
        if (!filter_var($website, FILTER_VALIDATE_URL)) {
            $errors["website"] = "Please enter a valid URL.";
        }
    }

    // --- Password ---
    $password = $_POST["password"] ?? "";
    $pwErrors = validate_password($password); // Our function from above
    if (!empty($pwErrors)) {
        $errors["password"] = "Password: " . implode(" ", $pwErrors);
    }

    // ============================================
    // 3. All valid? Process the data!
    // ============================================
    if (empty($errors)) {
        // Hash the password, save to database, etc.
        $success = true;
    }
}

// ============================================
// Helper: Check if a field has an error
// ============================================
function field_error(string $field, array $errors): string {
    if (isset($errors[$field])) {
        return '<span class="error">' . htmlspecialchars($errors[$field]) . '</span>';
    }
    return "";
}

function field_class(string $field, array $errors): string {
    return isset($errors[$field]) ? 'class="input-error"' : '';
}
?>

And the HTML portion of the same file:

<!-- In the HTML section of the same file -->
<?php if ($success): ?>
    <div class="alert success">
        <p>Account created successfully! Welcome, <?= htmlspecialchars($name) ?>.</p>
    </div>
<?php else: ?>
    <form method="post" action="" novalidate>
        <div class="form-group">
            <label for="name">Name *</label>
            <input type="text" id="name" name="name"
                   value="<?= htmlspecialchars($name) ?>"
                   <?= field_class("name", $errors) ?>>
            <?= field_error("name", $errors) ?>
        </div>

        <div class="form-group">
            <label for="email">Email *</label>
            <input type="email" id="email" name="email"
                   value="<?= htmlspecialchars($email) ?>"
                   <?= field_class("email", $errors) ?>>
            <?= field_error("email", $errors) ?>
        </div>

        <div class="form-group">
            <label for="age">Age *</label>
            <input type="number" id="age" name="age"
                   value="<?= htmlspecialchars($age) ?>"
                   <?= field_class("age", $errors) ?>>
            <?= field_error("age", $errors) ?>
        </div>

        <div class="form-group">
            <label for="website">Website (optional)</label>
            <input type="url" id="website" name="website"
                   value="<?= htmlspecialchars($website) ?>"
                   <?= field_class("website", $errors) ?>>
            <?= field_error("website", $errors) ?>
        </div>

        <div class="form-group">
            <label for="password">Password *</label>
            <input type="password" id="password" name="password"
                   <?= field_class("password", $errors) ?>>
            <?= field_error("password", $errors) ?>
            <small>Min 8 chars, must include uppercase, lowercase, digit, and special character.</small>
        </div>

        <button type="submit">Create Account</button>
    </form>
<?php endif; ?>

✅ Key Patterns to Notice

  • Errors are stored in an associative array keyed by field name — so each field shows its own error
  • Helper functions field_error() and field_class() keep the HTML clean
  • novalidate on the form disables browser validation for testing (remove in production)
  • Passwords are never made sticky — don't echo passwords back into the form
  • Optional fields validate only when they have a value

Best Practices for Error Messages

Bad Message Good Message Why
"Invalid input" "Name must be 2–50 characters" Tells the user exactly what to fix
"Error in field 3" "Email: Please include an @ sign" Identifies the field by name
"Bad email" "Please enter a valid email (e.g., name@example.com)" Shows the expected format
"Password wrong" "Password must include at least one uppercase letter" Specifies which rule failed
"Validation failed: FILTER_VALIDATE_INT" "Age must be a whole number between 13 and 120" Human language, not code constants
💡 Tip: Never reveal technical details in error messages shown to users. "SQL syntax error near 'DROP TABLE'" tells attackers what you're vulnerable to. Log technical errors; show friendly messages.

Hands-On Exercises

🏋️ Exercise 1: Validation Function Library

Objective: Build a set of reusable validation functions.

Instructions:

  1. Create the following functions, each returning true on success or an error message string on failure:
validate_required($value, $fieldName)    // Non-empty after trim
validate_email($value)                   // Valid email
validate_min_length($value, $min, $name) // Minimum string length
validate_max_length($value, $max, $name) // Maximum string length
validate_int_range($value, $min, $max, $name) // Integer in range
validate_in_list($value, $allowed, $name)     // Value is in allowed list
💡 Hint

Return true for valid, a string for invalid. The calling code checks: if ($result !== true) { $errors[] = $result; }

✅ Solution
<?php
function validate_required(string $value, string $fieldName): string|true {
    return trim($value) !== "" ? true : "$fieldName is required.";
}

function validate_email(string $value): string|true {
    $value = trim($value);
    if ($value === "") return "Email is required.";
    $sanitized = filter_var($value, FILTER_SANITIZE_EMAIL);
    return filter_var($sanitized, FILTER_VALIDATE_EMAIL)
        ? true
        : "Please enter a valid email address.";
}

function validate_min_length(string $value, int $min, string $name): string|true {
    return strlen(trim($value)) >= $min
        ? true
        : "$name must be at least $min characters.";
}

function validate_max_length(string $value, int $max, string $name): string|true {
    return strlen(trim($value)) <= $max
        ? true
        : "$name must be no more than $max characters.";
}

function validate_int_range(string $value, int $min, int $max, string $name): string|true {
    $result = filter_var($value, FILTER_VALIDATE_INT, [
        "options" => ["min_range" => $min, "max_range" => $max]
    ]);
    return $result !== false ? true : "$name must be a number between $min and $max.";
}

function validate_in_list(string $value, array $allowed, string $name): string|true {
    return in_array($value, $allowed, true)
        ? true
        : "$name must be one of: " . implode(", ", $allowed) . ".";
}

// Usage
$errors = [];
$checks = [
    validate_required($_POST["name"] ?? "", "Name"),
    validate_email($_POST["email"] ?? ""),
    validate_min_length($_POST["name"] ?? "", 2, "Name"),
    validate_int_range($_POST["age"] ?? "", 13, 120, "Age"),
    validate_in_list($_POST["role"] ?? "", ["user", "editor", "admin"], "Role"),
];

foreach ($checks as $result) {
    if ($result !== true) {
        $errors[] = $result;
    }
}

🏋️ Exercise 2: Contact Form with Full Validation

Objective: Build a fully validated, sticky, self-processing contact form.

Requirements:

  • Fields: Name (required, 2–50 chars, letters/spaces only), Email (required, valid format), Phone (optional, US format), Subject (required, select dropdown), Message (required, 10–1000 chars)
  • Per-field error messages displayed next to each input
  • All fields are sticky (except password if you add one)
  • Success message on valid submission
  • Use both filter_var() and regex validation
💡 Hint

Use the associative error array pattern: $errors["name"] = "...". Create helper functions for displaying errors next to fields. Validate the phone only if it's not empty.

✅ Solution
<?php
$name    = "";
$email   = "";
$phone   = "";
$subject = "";
$message = "";
$errors  = [];
$success = false;

if ($_SERVER["REQUEST_METHOD"] === "POST") {
    // Sanitize
    $name    = trim($_POST["name"] ?? "");
    $email   = trim($_POST["email"] ?? "");
    $email   = filter_var($email, FILTER_SANITIZE_EMAIL);
    $phone   = trim($_POST["phone"] ?? "");
    $subject = $_POST["subject"] ?? "";
    $message = trim($_POST["message"] ?? "");

    // Validate name
    if ($name === "") {
        $errors["name"] = "Name is required.";
    } elseif (strlen($name) < 2 || strlen($name) > 50) {
        $errors["name"] = "Name must be 2–50 characters.";
    } elseif (!preg_match('/^[a-zA-Z\s\'-]+$/', $name)) {
        $errors["name"] = "Name can only contain letters, spaces, hyphens, and apostrophes.";
    }

    // Validate email
    if ($email === "") {
        $errors["email"] = "Email is required.";
    } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $errors["email"] = "Please enter a valid email (e.g., name@example.com).";
    }

    // Validate phone (optional)
    if ($phone !== "") {
        $phoneDigits = preg_replace('/\D/', '', $phone);
        if (strlen($phoneDigits) < 10 || strlen($phoneDigits) > 11) {
            $errors["phone"] = "Please enter a valid US phone number.";
        }
    }

    // Validate subject
    $validSubjects = ["general", "support", "billing", "feedback"];
    if (!in_array($subject, $validSubjects)) {
        $errors["subject"] = "Please select a subject.";
    }

    // Validate message
    if ($message === "") {
        $errors["message"] = "Message is required.";
    } elseif (strlen($message) < 10) {
        $errors["message"] = "Message must be at least 10 characters.";
    } elseif (strlen($message) > 1000) {
        $errors["message"] = "Message must be under 1000 characters.";
    }

    if (empty($errors)) {
        $success = true;
    }
}

function err(string $field): string {
    global $errors;
    return isset($errors[$field])
        ? "<span class='error'>" . htmlspecialchars($errors[$field]) . "</span>" : "";
}
?>

<?php if ($success): ?>
    <p class="success">Thanks, <?= htmlspecialchars($name) ?>! Your message has been received.</p>
<?php else: ?>
    <form method="post" action="">
        <label>Name *<br>
            <input type="text" name="name" value="<?= htmlspecialchars($name) ?>">
        </label><?= err("name") ?><br>

        <label>Email *<br>
            <input type="email" name="email" value="<?= htmlspecialchars($email) ?>">
        </label><?= err("email") ?><br>

        <label>Phone (optional)<br>
            <input type="tel" name="phone" value="<?= htmlspecialchars($phone) ?>">
        </label><?= err("phone") ?><br>

        <label>Subject *<br>
            <select name="subject">
                <option value="">-- Select --</option>
                <?php foreach (["general"=>"General","support"=>"Support","billing"=>"Billing","feedback"=>"Feedback"] as $v=>$l): ?>
                    <option value="<?=$v?>" <?=$subject===$v?"selected":""?>><?=$l?></option>
                <?php endforeach; ?>
            </select>
        </label><?= err("subject") ?><br>

        <label>Message * (10–1000 chars)<br>
            <textarea name="message" rows="5"><?= htmlspecialchars($message) ?></textarea>
        </label><?= err("message") ?><br>

        <button type="submit">Send Message</button>
    </form>
<?php endif; ?>

🎯 Quick Quiz

Question 1: What's the difference between validation and sanitization?

Question 2: What does filter_var("0", FILTER_VALIDATE_INT) return?

Question 3: Why shouldn't you rely on HTML5 form validation alone?

Question 4: What does filter_input(INPUT_POST, "email", FILTER_VALIDATE_EMAIL) return if the key doesn't exist?

Question 5: When should you use htmlspecialchars()?

Summary

🎉 Key Takeaways

  • Never trust user input — client-side validation is a UX convenience, server-side validation is a security requirement
  • Sanitize then validate — clean the data first, then check if it meets your rules
  • filter_var() handles both validation (FILTER_VALIDATE_*) and sanitization (FILTER_SANITIZE_*)
  • Key validation filters: FILTER_VALIDATE_EMAIL, FILTER_VALIDATE_INT (with min/max range), FILTER_VALIDATE_URL, FILTER_VALIDATE_FLOAT, FILTER_VALIDATE_IP
  • filter_input() reads and filters GET/POST data in one step; returns null (missing), false (invalid), or the value (valid)
  • Use === false for filter comparisons — 0 and "" are valid results that evaluate as falsy
  • Custom regex for patterns PHP doesn't cover: usernames, phone numbers, ZIP codes, product codes
  • htmlspecialchars() is your primary XSS defense — use it on all user data displayed in HTML
  • Specific error messages help users fix problems; never expose technical details to end users
  • Store errors by field name ($errors["email"] = "...") so each field can show its own error

📚 Additional Resources

🚀 What's Next?

You can now receive, validate, and sanitize user input safely. In Lesson 13: File Handling & Includes, you'll learn to read and write files with PHP, organize your code with include/require, and build reusable components like headers and footers.

🎉 Congratulations!

You now know how to build forms that reject bad data before it ever reaches your application. This is a skill you'll use in every PHP project.