PHP Form Complete

sa sahaya jerish Updated 17 Oct 2026
9 min read ·Lesson 100 of 109
In this post

This lesson combines everything from the PHP Forms section into one complete, production-style form: a course enquiry form with validation, normalization, sticky values, error messages, CSRF protectio

This lesson combines everything from the PHP Forms section into one complete, production-style form: a course enquiry form with validation, normalization, sticky values, error messages, CSRF protection, a spam honeypot, the Post/Redirect/Get pattern, flash messages, safe output, and storage.


What the Finished Form Does

RequirementTechniqueLesson
Show the form on GET, process on POST$_SERVER['REQUEST_METHOD']PHP Form Handling
Reject forged submissionsCSRF token in the sessionPHP Form Handling
Catch simple spam botsHidden "honeypot" field(this lesson)
Check required fieldsTrimmed, type-safe checksPHP Form Required
Validate email and phonefilter_var(), regex, normalizationPHP Form URL/E-mail, Form Input Sanitizing
Validate choicesAllow-lists from option arraysCheckbox, Radio and Select Inputs
Keep the user's input after errorsFlashed old input + old() helperSticky Forms
Prevent duplicate submissionsRedirect after every POST (PRG)$_POST
Prevent XSShtmlspecialchars() via e()Escaping Output

Project Structure

enquiry/
├── public/
│   └── enquiry.php          ← the form page (controller + template)
├── src/
│   └── enquiry-functions.php ← reusable functions
└── storage/
    └── enquiries.jsonl      ← saved enquiries (outside the web root)

Functions live in src/, data in storage/, and only public/ is exposed by the web server. Enquiries are stored one JSON object per line (JSON Lines), which is easy to append and to import later; a real application would usually insert them into a database instead.


Step 1: Reusable Functions

src/enquiry-functions.php

<?php
declare(strict_types=1);

const COURSES = ['php' => 'PHP Full Course', 'mysql' => 'MySQL Essentials', 'laravel' => 'Laravel Framework'];
const MODES = ['online' => 'Online', 'offline' => 'Classroom (Chennai)'];

function e(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function old(array $old, string $key, string $default = ''): string
{
    $value = $old[$key] ?? $default;
    return is_string($value) ? $value : $default;
}

function inputString(array $input, string $key): string
{
    $value = $input[$key] ?? '';
    return is_string($value) ? trim($value) : '';
}

function csrfToken(): string
{
    if (empty($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['csrf_token'];
}

function isValidCsrfToken(mixed $token): bool
{
    return is_string($token)
        && isset($_SESSION['csrf_token'])
        && hash_equals($_SESSION['csrf_token'], $token);
}

function normalizeMobile(string $input): ?string
{
    $digits = preg_replace('/\D+/', '', $input) ?? '';

    if (strlen($digits) === 12 && str_starts_with($digits, '91')) {
        $digits = substr($digits, 2);
    }

    return preg_match('/^[6-9]\d{9}$/', $digits) === 1 ? $digits : null;
}

function validateEnquiry(array $input): array
{
    $data = [
        'name' => preg_replace('/\s+/u', ' ', inputString($input, 'name')) ?? '',
        'email' => inputString($input, 'email'),
        'phone' => inputString($input, 'phone'),
        'course' => inputString($input, 'course'),
        'mode' => inputString($input, 'mode'),
        'message' => inputString($input, 'message'),
        'consent' => ($input['consent'] ?? null) === 'yes',
    ];

    $errors = [];

    if ($data['name'] === '' || mb_strlen($data['name']) > 60) {
        $errors['name'] = 'Please enter your name.';
    }

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

    if ($data['phone'] !== '') {
        $phone = normalizeMobile($data['phone']);
        if ($phone === null) {
            $errors['phone'] = 'Enter a valid 10-digit mobile number or leave it empty.';
        } else {
            $data['phone'] = $phone;
        }
    }

    if (!array_key_exists($data['course'], COURSES)) {
        $errors['course'] = 'Please choose a course.';
    }

    if (!array_key_exists($data['mode'], MODES)) {
        $errors['mode'] = 'Please choose online or offline.';
    }

    $length = mb_strlen($data['message']);
    if ($length < 10 || $length > 1000) {
        $errors['message'] = 'Message must be between 10 and 1000 characters.';
    }

    if (!$data['consent']) {
        $errors['consent'] = 'Please agree to be contacted about your enquiry.';
    }

    return [$data, $errors];
}

function saveEnquiry(array $data, string $file): void
{
    $record = $data + ['received_at' => date(DATE_ATOM)];
    $line = json_encode($record, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . PHP_EOL;

    if (file_put_contents($file, $line, FILE_APPEND | LOCK_EX) === false) {
        throw new RuntimeException('Could not save the enquiry.');
    }
}

Testing the Validation Without a Browser

Because validateEnquiry() receives an array instead of reading $_POST, it can be tested directly:

<?php
require __DIR__ . '/src/enquiry-functions.php';

[$data, $errors] = validateEnquiry([
    'name' => '  Karthik   R ',
    'email' => 'karthik@example.com',
    'phone' => '+91 98765 43210',
    'course' => 'php',
    'mode' => 'online',
    'message' => 'I would like to join the weekend batch.',
    'consent' => 'yes',
]);

var_dump($errors === []);
echo "{$data['name']} | {$data['phone']} | {$data['course']}\n";

[, $errors] = validateEnquiry([
    'name' => '',
    'email' => 'karthik@',
    'phone' => '12345',
    'course' => 'java',
    'message' => 'Hi',
]);

print_r($errors);

Output

bool(true)
Karthik R | 9876543210 | php
Array
(
    [name] => Please enter your name.
    [email] => Please enter a valid email address.
    [phone] => Enter a valid 10-digit mobile number or leave it empty.
    [course] => Please choose a course.
    [mode] => Please choose online or offline.
    [message] => Message must be between 10 and 1000 characters.
    [consent] => Please agree to be contacted about your enquiry.
)
  • Valid input comes back normalized: extra spaces in the name are collapsed, and the phone number is reduced to 10 digits.
  • Invalid input produces one clear message per field, all reported together.
  • The optional phone field is validated only when something was entered.

Step 2: The Page — Controller and Template

public/enquiry.php

<?php
declare(strict_types=1);

require __DIR__ . '/../src/enquiry-functions.php';

session_start();

$storageFile = __DIR__ . '/../storage/enquiries.jsonl';

if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
    if (!isValidCsrfToken($_POST['csrf_token'] ?? null)) {
        http_response_code(419);
        exit('Your session has expired. Please reload the page and try again.');
    }

    // Honeypot: real users never see or fill this field
    if (inputString($_POST, 'website') !== '') {
        header('Location: /enquiry.php?sent=1', true, 303);
        exit;
    }

    [$data, $errors] = validateEnquiry($_POST);

    if ($errors !== []) {
        $_SESSION['_old'] = $data;
        $_SESSION['_errors'] = $errors;
        header('Location: /enquiry.php', true, 303);
        exit;
    }

    try {
        saveEnquiry($data, $storageFile);
    } catch (Throwable $e) {
        error_log('Enquiry not saved: ' . $e->getMessage());
        $_SESSION['_old'] = $data;
        $_SESSION['_errors'] = ['form' => 'Sorry, something went wrong. Please try again later.'];
        header('Location: /enquiry.php', true, 303);
        exit;
    }

    $_SESSION['_success'] = 'Thank you, ' . $data['name'] . '! Our team will contact you within one working day.';
    header('Location: /enquiry.php', true, 303);
    exit;
}

$old = $_SESSION['_old'] ?? [];
$errors = $_SESSION['_errors'] ?? [];
$success = $_SESSION['_success'] ?? null;
unset($_SESSION['_old'], $_SESSION['_errors'], $_SESSION['_success']);

$error = fn(string $field): string => isset($errors[$field])
    ? '<p class="error" id="' . e($field) . '-error">' . e($errors[$field]) . '</p>'
    : '';
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Course Enquiry</title>
</head>
<body>
<h1>Course Enquiry</h1>

<?php if ($success !== null): ?>
    <p class="success"><?= e($success) ?></p>
<?php endif; ?>
<?= $error('form') ?>

<form method="post" action="/enquiry.php" novalidate>
    <input type="hidden" name="csrf_token" value="<?= e(csrfToken()) ?>">

    <div style="position:absolute; left:-9999px" aria-hidden="true">
        <label>Website <input name="website" tabindex="-1" autocomplete="off"></label>
    </div>

    <label>Name *
        <input name="name" required maxlength="60" value="<?= e(old($old, 'name')) ?>">
    </label>
    <?= $error('name') ?>

    <label>Email *
        <input type="email" name="email" required value="<?= e(old($old, 'email')) ?>">
    </label>
    <?= $error('email') ?>

    <label>Mobile (optional)
        <input type="tel" name="phone" value="<?= e(old($old, 'phone')) ?>">
    </label>
    <?= $error('phone') ?>

    <label>Course *
        <select name="course" required>
            <option value="">-- Choose a course --</option>
            <?php foreach (COURSES as $value => $label): ?>
                <option value="<?= e($value) ?>"<?= old($old, 'course') === $value ? ' selected' : '' ?>><?= e($label) ?></option>
            <?php endforeach; ?>
        </select>
    </label>
    <?= $error('course') ?>

    <fieldset>
        <legend>Mode *</legend>
        <?php foreach (MODES as $value => $label): ?>
            <label><input type="radio" name="mode" value="<?= e($value) ?>"<?= old($old, 'mode') === $value ? ' checked' : '' ?>> <?= e($label) ?></label>
        <?php endforeach; ?>
    </fieldset>
    <?= $error('mode') ?>

    <label>Message *
        <textarea name="message" required minlength="10" maxlength="1000"><?= e(old($old, 'message')) ?></textarea>
    </label>
    <?= $error('message') ?>

    <label>
        <input type="checkbox" name="consent" value="yes"<?= ($old['consent'] ?? false) === true ? ' checked' : '' ?>>
        I agree to be contacted about this enquiry *
    </label>
    <?= $error('consent') ?>

    <button type="submit">Send enquiry</button>
</form>
</body>
</html>

How the Pieces Work Together

The Request Flow

GET  /enquiry.php          → empty form (+ CSRF token)
POST /enquiry.php (errors) → flash old input + errors → 303 → GET → form with values and messages
POST /enquiry.php (valid)  → save → flash success     → 303 → GET → thank-you message, empty form

Every POST ends with a redirect, so refreshing the page never resubmits the form, and the back button behaves predictably.

Step-by-Step Explanation

  1. Bootstrap: strict types, shared functions, and the session are loaded before any output.
  2. CSRF check: a POST without the correct token is rejected before anything else happens.
  3. Honeypot: the website field is moved off-screen and hidden from screen readers. Humans leave it empty; many simple bots fill every field. Spam submissions receive a normal-looking redirect, so bots do not learn that they were detected.
  4. Validation: validateEnquiry($_POST) returns normalized data and field-specific errors.
  5. Errors: the normalized input and the errors are flashed to the session; after the redirect, the template refills every field with old() and shows each message beside its field.
  6. Saving: the enquiry is appended to a JSON Lines file with an exclusive lock (LOCK_EX), so simultaneous submissions cannot corrupt the file. Failures are logged with error_log() — the visitor sees a friendly message, never technical details.
  7. Success: a flash message thanks the user by name. It is escaped by e() in the template, like every other dynamic value.
  8. Template: HTML attributes such as required and maxlength give instant feedback in the browser; novalidate is included here so you can observe the server-side validation while testing — remove it in production for the best user experience.

Security Checklist

ThreatProtection in This Form
Cross-site scriptingEvery output passes through e()
Cross-site request forgerySession-bound CSRF token checked with hash_equals()
Manipulated choicesCourse and mode validated against allow-lists
Unexpected input typesinputString() ignores arrays
Duplicate submissionsPost/Redirect/Get
Spam botsHoneypot field (add rate limiting or a CAPTCHA for high-traffic forms)
Data exposureStorage outside the public folder; errors logged, not displayed

Extending the Form

  • Database storage: replace saveEnquiry() with an INSERT using a PDO prepared statement (see PDO Prepared Statements).
  • Email notification: send a confirmation to the user and a notification to your team, using a mail library such as PHPMailer or Symfony Mailer rather than raw mail().
  • Rate limiting: count submissions per IP address or session within a time window and reject excessive requests.
  • Reusable form classes: in frameworks, form requests or form components encapsulate the same steps — the concepts in this section are exactly what they automate.

Common Mistakes in Complete Forms

MistakeConsequenceFix
Validating only in the browserInvalid and malicious data is savedValidate in PHP
Missing CSRF protectionAttackers submit forms on users' behalfToken in every POST form
Rendering a page directly after POSTDuplicate submissions on refreshRedirect with 303
Losing input after errorsFrustrated usersFlash old input; use old()
Showing exception messages to usersInformation leakageLog details, show a generic message
Storing data inside the web rootFiles downloadable by URLStore outside public/ or in a database

Best Practices

  • Keep validation in a reusable, testable function that returns data and errors.
  • Process POST requests before output, and always redirect afterwards.
  • Escape every value in the template, including flash messages and error texts.
  • Protect forms with CSRF tokens and basic spam defenses.
  • Log technical errors and show users clear, friendly messages.

Official reference: https://www.php.net/manual/en/tutorial.forms.php

0 Comments

Reviewed before they appear

No comments yet.

PHP
PHP Home PHP Switch PHP Match Alternative Syntax PHP Loops While Loop Do While Loop For Loop Foreach Loop Nested Loops Break Statement PHP Home PHP Intro PHP Install PHP Built-in Web Server PHP Syntax PHP Tags PHP and HTML PHP Comments PHP Multiline Comments PHP Variables Variables Scope PHP Casting Logical Operators Null Coalescing Operator Spaceship Operator Operator Precedence Continue Statement Modify Strings PHP If...Else...Elseif PHP If Modify Strings PHP If Operators PHP Functions Concatenate Strings PHP If...Else Slicing Strings User-defined Functions Slicing Strings PHP Shorthand if Escape Characters Default Argument Values PHP Nested if Pass by Reference String Interpolation Named Arguments PHP File Upload Variadic Functions Return Values Search in Strings Multibyte Strings Type Declarations in Functions PHP Numbers PHP Math PHP Floats PHP Random Numbers PHP Constants PHP Properties PHP Methods PHP Constructor Constructor Property Promotion PHP Cookies PHP Destructor PHP Access Modifiers Readonly Properties PHP Sessions PHP Inheritance Method Overriding PHP Filters The final Keyword CSRF Protection PHP Filters Advanced Session Security Secure Cookies PHP Magic Constants File Upload Security PHP Callback Functions PHP Operators Filesystem Security PHP JSON Arithmetic Operators Error Reporting in Production Hiding PHP Assignment Operators PHP Configuration (php.ini) Comparison Operators PHP Command Line (CLI) PHP Exceptions Increment / Decrement Operators Migrating to PHP 8 PHP Form Validation PHP Form Required PHP Form URL/E-mail Error Handling Form Input Sanitizing Escaping Output (htmlspecialchars) Custom Error Handler Checkbox, Radio and Select Inputs Sticky Forms PHP Form Complete PHP Date and Time Date Formatting Error Logging HTTP Headers and Redirects Output Buffering Sending Email PHP cURL Consuming REST APIs PHP Generators
Ask about this post
AI Ask about this post

Ask questions about PHP Form Complete and get answers drawn from it.

Signed-in readers only.