HTML Forms: Validation, Repopulation, and CSRF
Build accessible HTML forms with GET/POST methods and proper controls, handle multipart submissions, validate input server-side with field-level errors and value repopulation, and protect state-changing forms with CSRF tokens.
Forms are the primary way a website collects information from a visitor, and getting them right in PHP means more than just reading $_POST. A solid form does five things well: it uses the right HTTP method for what it does, it exposes controls that are usable with a keyboard and a screen reader, it validates every field on the server (never trusting client-side validation alone), it shows the visitor exactly what went wrong without making them retype everything, and it defends itself against being submitted by a different site on the visitor's behalf (CSRF). This lesson builds a complete PHP form-handling flow covering all of that.
Choosing GET vs POST, and Form Controls
A <form>'s method attribute decides how the browser sends its data. method="get" appends fields to the URL as a query string — appropriate for search boxes and filters, where bookmarking or sharing the resulting URL is desirable and no server state changes. method="post" sends fields in the request body — the right choice for anything that creates, updates, or deletes data, logs a user in, or includes a file upload, because POST bodies aren't cached, bookmarked, or left sitting in browser history and server logs the way a GET URL is.
The action attribute sets where the form submits (omit it to submit to the current URL); common controls are <input type="text|email|password|number|date|checkbox|radio|hidden|file">, <textarea>, and <select>/<option>. HTML5 input types like email and number give the browser a head start on built-in client-side validation and better mobile keyboards, but that is a UX convenience only — it is trivially bypassed (disable JS, use curl, edit the DOM) and must never be relied on as the actual security or correctness check.
Every meaningful control needs a name attribute — that's the key PHP will see in $_GET/$_POST — and should be paired with a <label> (via for/id, or by wrapping the input) so both sighted mouse users and screen-reader/keyboard users know what the field is for.
• method="get" — for reads/searches/filters; data lands in $_GET and the URL. • method="post" — for state-changing actions and file uploads; data lands in $_POST/$_FILES. • Client-side HTML5 validation (required, type="email", pattern) improves UX but is never a substitute for server-side validation. • Every input needs name (the array key PHP receives) and an associated <label>.
multipart/form-data and File Uploads
By default a form's enctype is application/x-www-form-urlencoded, which URL-encodes field values into the body — fine for plain text fields but unable to carry binary file data efficiently. Any form with a <input type="file"> control must set enctype="multipart/form-data", which splits the body into discrete named parts, each with its own headers (including the original filename and declared content type for file parts), separated by a boundary string the browser generates.
On the PHP side, multipart requests populate both $_POST (for the non-file text fields) and $_FILES (for the file fields) — see the Request Handling lesson for the exact shape of $_FILES and how to validate and move an uploaded file safely. Remember that method must be "post" for multipart/form-data to work at all; GET requests cannot carry a body of that shape.
• enctype="multipart/form-data" is required whenever the form includes a file input. • Must be paired with method="post". • Populates $_POST (text fields) and $_FILES (file fields) together. • Server must still enforce size limits (upload_max_filesize/post_max_size ini directives) and validate file content, not just rely on the form.
Server-Side Validation, Field Errors, and Repopulation
Server-side validation is not optional, because the client (browser HTML5 checks, JS validation) can always be bypassed — by disabling JavaScript, using a tool like curl or Postman, or simply editing the DOM before submitting. The server is the only checkpoint that's actually enforced, so every field needs the same checks there that the client-side hints suggest, plus checks the client never bothered to hint at (uniqueness, business rules, size limits).
A good validation flow collects errors per field into an associative array (['email' => 'Must be a valid email address', ...]) rather than stopping at the first failure, so the visitor sees everything wrong with their submission in one pass instead of playing whack-a-mole one error at a time. When there are errors, the form is re-rendered — not redirected away — with the visitor's previously-typed values pre-filled (repopulation) and each field's specific error displayed right next to that field (field-level errors), using htmlspecialchars() on every repopulated value to avoid re-introducing an XSS hole through the error page itself.
A common, robust pattern is the Post/Redirect/Get (PRG) pattern for the success case: after a successful POST, redirect (HTTP 303) to a GET URL (like a confirmation or listing page) so a page refresh doesn't resubmit the form. On validation failure, though, you typically re-render the same POST response directly (no redirect) precisely so you can show the field errors and repopulated values without losing them across a redirect.
• Never trust client-side (HTML5 or JS) validation alone — always re-validate on the server. • Collect all field errors in one pass (['field' => 'message']) instead of failing at the first one. • Repopulate submitted values into the re-rendered form, HTML-escaped, so the user doesn't retype everything. • Use Post/Redirect/Get after a successful POST to avoid duplicate-submission-on-refresh; re-render directly (no redirect) on validation failure so errors/values survive.
CSRF Protection and Accessible Markup
Cross-Site Request Forgery (CSRF) is an attack where a malicious page tricks a logged-in visitor's browser into submitting a request to your site — the browser automatically attaches the visitor's cookies, so your server sees what looks like a legitimate, authenticated request the visitor never actually meant to send (e.g. an auto-submitting hidden form on an attacker's page that posts to /transfer-funds). The standard defense is a CSRF token: a random, unguessable value generated per-session (or per-form), embedded in a hidden field, and verified on submission — a cross-site attacker's page has no way to read and include that token, because same-origin policy blocks it from reading your site's response.
Generate the token with a cryptographically secure random source (random_bytes() / bin2hex()), store it in $_SESSION, and compare submitted vs. stored using hash_equals() — a constant-time comparison that avoids leaking timing information an attacker could exploit to guess the token byte by byte. Regenerate the token after it's consumed (or per-session at minimum) so old tokens can't be replayed.
Accessible forms pair every input with a <label for="id">, group related radio/checkbox sets in <fieldset> with a descriptive <legend>, mark required fields both visually and with the required attribute (plus aria-required="true" where extra clarity helps), and connect an error message to its field with aria-describedby pointing at the error's id, plus aria-invalid="true" on the field itself when it has an error — so assistive technology announces exactly what's wrong and to which field, not just a generic 'form has errors' banner at the top.
• CSRF token: random_bytes()-generated, stored in $_SESSION, embedded as a hidden field, checked with hash_equals() (constant-time). • <label for> / wrapping labels, <fieldset>/<legend> for grouped controls. • aria-invalid="true" + aria-describedby="error-id" links a field to its specific error message for screen readers. • required + aria-required for mandatory fields; never rely on color alone to indicate an error.
CSRF Token Generate + Verify
hash_equals() prevents a timing-attack from being used to guess the token character by character. Regenerate the token after a successful sensitive action (e.g. login) to prevent reuse. The token must live in $_SESSION (server-side), not in a plain cookie the attacker's cross-site request would also carry.
session_start();
// Generate once per session and embed in every state-changing form
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$csrfToken = $_SESSION['csrf_token'];
// <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">
// On submission, verify with a constant-time comparison
$submitted = $_POST['csrf_token'] ?? '';
if (!hash_equals($_SESSION['csrf_token'] ?? '', $submitted)) {
http_response_code(403);
exit('Invalid or expired form submission.');
}Code Examples
A GET form for a read-only, idempotent search: results land at a shareable/bookmarkable URL like /search.php?q=php, and the single control has a proper label and required attribute.
<form action="/search.php" method="get" role="search"> This form covers GET-vs-POST correctness (POST, because it creates state), multipart/form-data for the file input, a hidden CSRF token, labeled/grouped controls (fieldset+legend for the radio set), repopulation of previously submitted values via $old, and field-level error markup with aria-invalid/aria-describedby. novalidate disables the browser's built-in popup validation on this teaching example so the server-side error flow below is what actually runs and is visible; in production you'd typically keep both as layered UX + security.
<?= isset($errors['email']) ? 'aria-invalid="true" aria-describedby="email-error"' : '' ?>
The handler checks the CSRF token before doing anything else, repopulates $old from the submission up front (so it's available even on failure), collects every validation error into $errors instead of exiting at the first one, and only redirects (PRG pattern) on full success — a failed pass falls through to re-render the form with errors and values intact.
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'] ?? '')) {
Common mistake: Redirecting on validation failure (losing $errors/$old across the redirect) or checking CSRF after already processing the submission's side effects.
<form action="/search.php" method="get" role="search">
<label for="q">Search articles</label>
<input type="search" id="q" name="q" required>
<button type="submit">Search</button>
</form><form action="/signup.php" method="post" enctype="multipart/form-data" novalidate>
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken, ENT_QUOTES) ?>">
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" name="email" required
value="<?= htmlspecialchars($old['email'] ?? '', ENT_QUOTES) ?>"
<?= isset($errors['email']) ? 'aria-invalid="true" aria-describedby="email-error"' : '' ?>>
<?php if (isset($errors['email'])): ?>
<p id="email-error" class="error"><?= htmlspecialchars($errors['email'], ENT_QUOTES) ?></p>
<?php endif; ?>
</div>
<fieldset>
<legend>Account type</legend>
<label><input type="radio" name="plan" value="free"
<?= ($old['plan'] ?? 'free') === 'free' ? 'checked' : '' ?>> Free</label>
<label><input type="radio" name="plan" value="pro"
<?= ($old['plan'] ?? '') === 'pro' ? 'checked' : '' ?>> Pro</label>
</fieldset>
<div class="field">
<label for="avatar">Profile photo (optional)</label>
<input type="file" id="avatar" name="avatar" accept="image/png,image/jpeg">
</div>
<button type="submit">Create account</button>
</form><?php
declare(strict_types=1);
session_start();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$csrfToken = $_SESSION['csrf_token'];
$errors = [];
$old = ['email' => '', 'plan' => 'free'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'] ?? '')) {
http_response_code(403);
exit('Invalid form submission (CSRF check failed). Please reload and try again.');
}
// Repopulate immediately so a failed pass still has the user's input to show back.
$old['email'] = trim((string) ($_POST['email'] ?? ''));
$old['plan'] = ($_POST['plan'] ?? 'free') === 'pro' ? 'pro' : 'free';
// Validate every field; collect ALL errors instead of stopping at the first.
if ($old['email'] === '' || filter_var($old['email'], FILTER_VALIDATE_EMAIL) === false) {
$errors['email'] = 'Please enter a valid email address.';
}
if (isset($_FILES['avatar']) && $_FILES['avatar']['error'] !== UPLOAD_ERR_NO_FILE) {
if ($_FILES['avatar']['error'] !== UPLOAD_ERR_OK) {
$errors['avatar'] = 'There was a problem uploading that file.';
…Validation Round-Trip
Errors keep the visitor on the form with their input intact; success redirects (PRG) to a fresh GET page.
Visitor fills form ──▶ POST /signup.php
│ │
│ 1. Check CSRF token (hash_equals)
│ 2. Repopulate $old[...] from $_POST
│ 3. Validate every field → collect $errors
│ │
│ ┌───────────┴────────────┐
│ errors empty errors present
│ │ │
│ Save + 303 redirect Re-render SAME form with
│ (Post/Redirect/Get) $old values + $errors shown
▼ │ │
GET /welcome.php ◀──── ▼
Visitor sees exactly what to fix,
without retyping correct fields.Important Rules
GET for reads/searches (shareable, bookmarkable, no state change); POST for anything that changes server state or uploads files.
enctype="multipart/form-data" + method="post" whenever the form has a file input.
Always re-validate on the server — client-side HTML5/JS validation is a UX aid only, never a security boundary.
Collect all field errors in one pass; repopulate submitted values (HTML-escaped) rather than making the user retype.
Generate a CSRF token with random_bytes(), store it server-side in $_SESSION, and verify with hash_equals() before processing any state-changing POST.
Pair every input with a <label>, group related controls with <fieldset>/<legend>, and link errors to fields with aria-describedby/aria-invalid.
Frameworks Automate the Pattern, Not the Principle
Frameworks like Laravel (Form Requests, old() helper, @csrf Blade directive) and Symfony (the Form component, automatic CSRF tokens) automate exactly this flow — validation rule sets, automatic old-input repopulation, and CSRF tokens injected into every form — because it is so consistently needed across every real application. Learning it by hand in plain PHP first means you understand precisely what those framework features are doing for you, and you can replicate the same safety even in a small script that doesn't use a framework at all.
<form method="post" action="/comment.php"> <input name="comment"> <button>Post</button> </form> // comment.php just does: save($_POST['comment']);
<form method="post" action="/comment.php"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>"> <label for="comment">Comment</label> <textarea id="comment" name="comment" required></textarea> <button>Post</button> </form> // comment.php: verify CSRF token with hash_equals(), THEN validate length/content, THEN save.
A POST form with no CSRF token and a handler that saves $_POST data straight through is vulnerable both to CSRF (any site can auto-submit this form using the visitor's session) and to saving garbage/oversized/malicious input with no validation at all.
if (empty($_POST['email']) || !filter_var($_POST['email'], FILTER_VALIDATE_EMAIL)) { exit('Invalid email'); } // user has to start the whole form over, everything they typed is gone
$errors = []; $old = ['email' => trim((string)($_POST['email'] ?? ''))]; if ($old['email'] === '' || !filter_var($old['email'], FILTER_VALIDATE_EMAIL)) { $errors['email'] = 'Please enter a valid email address.'; } // ...check every other field too, collecting into $errors... // then re-render the form with $old and $errors instead of exit()ing
Bailing out with exit() on the first invalid field, with no repopulation, forces the visitor to retype the entire form and never tells them about any other problems in the same submission — a frustrating, inaccessible experience. Collecting all errors and repopulating values is the expected pattern.
[multiple-choice] Why must client-side (HTML5/JS) form validation never be relied on as the only validation? A. Browsers don't support HTML5 validation attributes B. It can always be bypassed (disabled JS, direct HTTP requests, edited DOM), so the server is the only enforced checkpoint C. PHP cannot read values from forms that use client-side validation D. Client-side validation is slower than server-side validation
B. It can always be bypassed (disabled JS, direct HTTP requests, edited DOM), so the server is the only enforced checkpoint — A client can trivially skip client-side checks by disabling JavaScript or sending a raw HTTP request with a tool like curl. The server must independently validate every field, since it's the only checkpoint that's actually enforced.
[multiple-choice] What is the purpose of comparing a submitted CSRF token with hash_equals() instead of ==? A. hash_equals() is faster than == B. hash_equals() performs a constant-time comparison, preventing a timing attack from leaking the token byte by byte C. == cannot compare two strings in PHP D. hash_equals() automatically regenerates the token
B. hash_equals() performs a constant-time comparison, preventing a timing attack from leaking the token byte by byte — A naive == comparison can return faster or slower depending on how many leading characters match, which an attacker can measure over many requests to guess the token one byte at a time. hash_equals() takes the same amount of time regardless of where a mismatch occurs.
[true-false] True or false: a form with a file upload input must use method="post" and enctype="multipart/form-data". A. True B. False
A. True — File uploads require a request body that can carry binary data as discrete parts, which only multipart/form-data provides, and multipart bodies can only be sent via POST.
Build a small 'contact us' PHP form: POST method, fields name (required, 1-80 chars) and message (required, 1-1000 chars), a CSRF hidden field, and a <fieldset> asking 'How did you hear about us?' with three radio options. On submit, validate both fields into an $errors array, repopulate $old on failure, verify the CSRF token with hash_equals() before doing anything else, and on full success redirect (303) to /thanks.php. Hint: Structure it as: session_start() + token setup, then if POST: check CSRF, populate $old, validate into $errors, if empty($errors) redirect else fall through, then the HTML form using $old/$errors/$csrfToken.
Try it yourself — there's no single right answer here; compare your result against this lesson's examples.