PHP Form Complete
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
| Requirement | Technique | Lesson |
|---|---|---|
| Show the form on GET, process on POST | $_SERVER['REQUEST_METHOD'] | PHP Form Handling |
| Reject forged submissions | CSRF token in the session | PHP Form Handling |
| Catch simple spam bots | Hidden "honeypot" field | (this lesson) |
| Check required fields | Trimmed, type-safe checks | PHP Form Required |
| Validate email and phone | filter_var(), regex, normalization | PHP Form URL/E-mail, Form Input Sanitizing |
| Validate choices | Allow-lists from option arrays | Checkbox, Radio and Select Inputs |
| Keep the user's input after errors | Flashed old input + old() helper | Sticky Forms |
| Prevent duplicate submissions | Redirect after every POST (PRG) | $_POST |
| Prevent XSS | htmlspecialchars() 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
- Bootstrap: strict types, shared functions, and the session are loaded before any output.
- CSRF check: a POST without the correct token is rejected before anything else happens.
- Honeypot: the
websitefield 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. - Validation:
validateEnquiry($_POST)returns normalized data and field-specific errors. - 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. - 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 witherror_log()— the visitor sees a friendly message, never technical details. - Success: a flash message thanks the user by name. It is escaped by
e()in the template, like every other dynamic value. - Template: HTML attributes such as
requiredandmaxlengthgive instant feedback in the browser;novalidateis included here so you can observe the server-side validation while testing — remove it in production for the best user experience.
Security Checklist
| Threat | Protection in This Form |
|---|---|
| Cross-site scripting | Every output passes through e() |
| Cross-site request forgery | Session-bound CSRF token checked with hash_equals() |
| Manipulated choices | Course and mode validated against allow-lists |
| Unexpected input types | inputString() ignores arrays |
| Duplicate submissions | Post/Redirect/Get |
| Spam bots | Honeypot field (add rate limiting or a CAPTCHA for high-traffic forms) |
| Data exposure | Storage outside the public folder; errors logged, not displayed |
Extending the Form
- Database storage: replace
saveEnquiry()with anINSERTusing 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
| Mistake | Consequence | Fix |
|---|---|---|
| Validating only in the browser | Invalid and malicious data is saved | Validate in PHP |
| Missing CSRF protection | Attackers submit forms on users' behalf | Token in every POST form |
| Rendering a page directly after POST | Duplicate submissions on refresh | Redirect with 303 |
| Losing input after errors | Frustrated users | Flash old input; use old() |
| Showing exception messages to users | Information leakage | Log details, show a generic message |
| Storing data inside the web root | Files downloadable by URL | Store 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