The Shape of a Form-Handling Page
A contact form is the first genuinely useful thing most people build with PHP, and it teaches almost every idea in the language at once. Before the code, get the structure right.
The traditional approach puts the form in one file and the handler in another. The self-processing page is better: one file that checks whether it was reached by GET or POST. On GET it shows the form; on POST it validates the submission, and either shows the form again with error messages and the user's values still filled in, or accepts the data and redirects. Everything the visitor sees stays at one URL, and there is no second file to keep in sync.
Three details in the HTML matter. method="post" makes the values arrive in $_POST; without it the browser defaults to GET and pushes everything into the URL. The name attribute on each field becomes the array key — name="email" gives you $_POST['email'] — and a field with no name is not submitted at all, which is a quietly common bug. Leaving action empty submits back to the current URL, which is exactly what a self-processing page wants.
HTML5 attributes such as required, type="email" and maxlength are worth adding. They give instant feedback and reduce pointless round trips. But they run in the browser, so they are a convenience, not a check. Your PHP must validate everything again, as if none of them existed.
<?php
// contact.php - one file, both jobs
$errors = [];
$old = ['name' => '', 'email' => '', 'message' => ''];
$sent = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// ... validation goes here (next section)
}
?>
<!DOCTYPE html>
<html lang="en">
<body>
<h1>Contact us</h1>
<form method="post" action="">
<label for="name">Name</label>
<input type="text" id="name" name="name" required maxlength="80">
<label for="email">Email</label>
<input type="email" id="email" name="email" required>
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required></textarea>
<button type="submit">Send message</button>
</form>
</body>
</html> - A submit button only sends its own name and value when it is the button that was clicked. That makes
<button type="submit" name="action" value="delete">a clean way to tell two buttons apart in one form, using$_POST['action'].
Validation: Collect Errors, Do Not Stop at the First
Validation means checking that what arrived is what you can actually use. The pattern that works well is an $errors array: run every check, add a message for each failure, and at the end look at whether the array is empty. Stopping at the first error and printing it forces the visitor to submit five times to discover five problems, which is a genuinely annoying experience.
Key the array by field name rather than using a plain list. That lets you print each message next to the field it belongs to instead of in a block at the top, which is what modern forms do and what your users expect.
Trim text before checking it, or a message of three spaces passes an emptiness test. Check length in characters with mb_strlen(), not strlen(), so that a name written in an Indian script is measured fairly. And validate the email with filter_var() rather than a regular expression you found online — the rules for what constitutes a valid address are far more complicated than they look, and PHP's built-in filter already handles them.
Be careful to distinguish validation from sanitisation. Validation asks "is this acceptable?" and rejects it if not. Sanitisation changes the value to make it acceptable. For user-facing text you almost always want validation: silently altering what someone typed produces confusing results, and it does not make the value safe for any particular output context anyway. Store what they wrote, then escape it correctly wherever you put it.
Finally, do not escape at validation time. A common mistake is running htmlspecialchars() on the way in and storing the result in the database. Now the stored value is HTML-encoded, and it is wrong the moment you need it in an email, a CSV export or a JSON response. Escape at the point of output, every time, for the specific format you are producing.
<?php
$errors = [];
$old = ['name' => '', 'email' => '', 'message' => ''];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$old['name'] = trim($_POST['name'] ?? '');
$old['email'] = trim($_POST['email'] ?? '');
$old['message'] = trim($_POST['message'] ?? '');
if ($old['name'] === '') {
$errors['name'] = 'Please enter your name.';
} elseif (mb_strlen($old['name']) > 80) {
$errors['name'] = 'Name must be 80 characters or fewer.';
}
if ($old['email'] === '') {
$errors['email'] = 'Please enter your email address.';
} elseif (!filter_var($old['email'], FILTER_VALIDATE_EMAIL)) {
$errors['email'] = 'That email address does not look valid.';
}
if ($old['message'] === '') {
$errors['message'] = 'Please write a message.';
} elseif (mb_strlen($old['message']) < 10) {
$errors['message'] = 'Please write at least 10 characters.';
} elseif (mb_strlen($old['message']) > 2000) {
$errors['message'] = 'Message must be 2000 characters or fewer.';
}
if ($errors === []) {
// save to the database, send the email, then redirect
}
} - Write validation rules that match what you will actually do with the value. If the message goes into a
VARCHAR(2000)column, check for 2000 characters in PHP — otherwise MySQL either truncates it silently or throws an error, and neither is a good experience for the person who typed it.
Sticky Forms and Escaping on Output
When validation fails, redisplay the form with the visitor's values still in it. Losing a long message because one field was wrong is the fastest way to make someone give up on your site. This is called a sticky form, and it means printing each submitted value back into the field's value attribute.
That printing is exactly where cross-site scripting happens, so it has to be escaped. And there is a specific detail here: a value goes inside a double-quoted HTML attribute, so a stray quote in the data would end the attribute early and let the rest be read as markup. htmlspecialchars() with the ENT_QUOTES flag converts both quote characters and closes that hole. In PHP 8.1 and later ENT_QUOTES is included in the default flags, but writing it explicitly is clear and keeps the code correct on older versions.
Textareas are different from text inputs. Their content sits between the opening and closing tags rather than in an attribute, so you print the escaped value there — and note that <textarea> has no value attribute at all, which surprises people the first time.
A helper function is worth writing once. A short e() that wraps htmlspecialchars() with the right flags and encoding makes escaping so cheap to type that you stop skipping it — which is the actual goal. Forgetting to escape is not usually a knowledge problem, it is a friction problem.
The same care applies to error messages. If a message includes something the user typed — "the email address X is already registered" — that fragment must be escaped too. Attackers look precisely for the error path, because it is the one developers test least.
<?php
function e(?string $value): string {
return htmlspecialchars($value ?? '', ENT_QUOTES, 'UTF-8');
}
?>
<form method="post" action="">
<label for="name">Name</label>
<input type="text" id="name" name="name"
value="<?= e($old['name']) ?>"
class="<?= isset($errors['name']) ? 'is-invalid' : '' ?>">
<?php if (isset($errors['name'])): ?>
<p class="error"><?= e($errors['name']) ?></p>
<?php endif; ?>
<label for="email">Email</label>
<input type="email" id="email" name="email" value="<?= e($old['email']) ?>">
<?php if (isset($errors['email'])): ?>
<p class="error"><?= e($errors['email']) ?></p>
<?php endif; ?>
<label for="message">Message</label>
<!-- a textarea has no value attribute - the text goes between the tags -->
<textarea id="message" name="message" rows="5"><?= e($old['message']) ?></textarea>
<button type="submit">Send message</button>
</form>
<!-- Why ENT_QUOTES matters. Suppose the name field contained:
" onmouseover="alert(1)
Unescaped, that closes the value attribute and adds an event handler.
Escaped, it is just odd-looking text inside the box. --> - Selects, radios and checkboxes are sticky in a different way: you re-add the
selectedorcheckedattribute rather than a value.<option value="CSE" <?= $old['branch'] === 'CSE' ? 'selected' : '' ?>>is the usual shape.
Post/Redirect/Get: Stopping the Double Submission
If you handle a POST and then simply print "Thank you", the browser's address bar still holds a POST result. Press F5, and the browser offers to resubmit the form — and many people click through that dialog without reading it. Your database now has two identical enquiries, or two orders, or two payments.
The fix is a pattern called Post/Redirect/Get. After a successful POST, do not print anything. Send a redirect with header('Location: ...') and then exit. The browser makes a fresh GET request for the success page, and that GET is what ends up in the history. Refreshing now just reloads a harmless page.
Two details are essential. First, always call exit immediately after header('Location: ...'). A redirect header does not stop your script — without exit, the rest of the page keeps running, and any database work below it still happens. Second, the redirect target should be a URL you control, never a value taken from user input, or you have built an open redirect that helps attackers make their phishing links look like they point at your site.
To show a success message after the redirect, put it in the session before redirecting and read-then-delete it on the next page. This is usually called a flash message. It requires session_start() on both pages, which the next lesson covers in detail.
One more caution that follows directly: header() only works if nothing has been sent to the browser yet. A single blank line above your opening <?php tag counts as output and produces the "headers already sent" error. This is why a form-handling page must do all of its logic before any HTML.
<?php
session_start();
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// ... validation as before ...
if ($errors === []) {
$stmt = $pdo->prepare(
'INSERT INTO enquiries (name, email, message, created_at)
VALUES (?, ?, ?, NOW())'
);
$stmt->execute([$old['name'], $old['email'], $old['message']]);
$_SESSION['flash'] = 'Thanks! We will reply within two working days.';
header('Location: contact.php?sent=1');
exit; // never omit this
}
}
// Read the flash message once, then remove it
$flash = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);
?>
<?php if ($flash !== null): ?>
<p class="success"><?= htmlspecialchars($flash, ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>
<!-- Never redirect to a URL that came from the request:
header('Location: ' . $_GET['next']); <- open redirect
Use an allow-list of known destinations instead. --> - "Cannot modify header information — headers already sent by (output started at line N)" tells you the exact file and line where output began. Look there first: it is almost always a space before
<?php, a stray line after a closing?>in an included file, or a debuggingechosomebody forgot to delete.
CSRF: Proving the Request Came From Your Own Form
Here is an attack that surprises most beginners. A visitor is logged into your site. They open another tab and land on a page that quietly contains a form pointing at your delete or transfer endpoint, submitted automatically by a line of JavaScript. Their browser attaches your site's cookies, because that is what browsers do, and your server sees a perfectly ordinary authenticated request. This is cross-site request forgery.
Notice what does not help. Checking that the user is logged in does not help, because they are. Using POST instead of GET does not help, because another site can post to yours. Checking the referer header does not help, because it is optional and unreliable.
The defence is a secret that only your own page could know. Generate a random token, store it in the session, and put it in a hidden field of every form that changes something. When the form comes back, compare the submitted token with the one in the session. The attacker's page cannot read your session and cannot read your HTML, so it cannot supply the right token.
Two implementation details matter. Generate the token with random_bytes(), which is a cryptographically secure source — rand() and mt_rand() are predictable and unsuitable. And compare with hash_equals() rather than ===: it compares in constant time, so an attacker cannot learn the token character by character from tiny timing differences.
Modern browsers also offer a second layer through the SameSite cookie attribute, which stops cookies being sent on cross-site requests. Setting SameSite=Lax on your session cookie is genuinely valuable and is covered in the sessions lesson. Use both: the cookie attribute is a browser behaviour you do not control, while the token is a check your own server performs.
<?php
session_start();
// Create the token once per session
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$submitted = $_POST['csrf_token'] ?? '';
if (!hash_equals($_SESSION['csrf_token'], $submitted)) {
http_response_code(419);
exit('Your session expired. Please reload the page and try again.');
}
// ... only now validate and save
}
?>
<form method="post" action="">
<input type="hidden" name="csrf_token"
value="<?= htmlspecialchars($_SESSION['csrf_token'], ENT_QUOTES, 'UTF-8') ?>">
<input type="text" name="name">
<button type="submit">Send</button>
</form> - A CSRF token belongs on every form that changes state: create, update, delete, login, logout, settings. It is not needed on a search form that only reads data, because there is nothing for an attacker to gain by making you search.
Putting It Together, and Handling Spam
A production-quality form page follows a fixed order, and keeping to that order is what prevents most of the bugs in this lesson. Session first, then the CSRF check, then validation, then the database write, then the redirect — and only after all of that, any HTML.
Public contact forms attract automated spam within days of going live. The cheapest effective defence is a honeypot: add a field that is hidden with CSS and that a human will never fill in. Most spam bots fill in every field they find, so any submission with a value in that field can be silently discarded. It costs nothing and, unlike a captcha, asks nothing of your real users.
Rate limiting is the other simple measure — record the time of the last submission in the session and reject anything sent within a few seconds of it. Neither of these is perfect, and a busy public form eventually needs something stronger, but together they remove the large majority of low-effort spam.
One honest note about email. PHP's built-in mail() function often fails on shared hosting, and messages it does send are frequently classified as spam because they lack proper authentication. If your form needs to deliver email reliably, save the enquiry to the database first — so nothing is ever lost — and send through a proper mail library configured with SMTP credentials.
- Start the session, and create the CSRF token if it does not exist
- If the request is not POST, just show the form
- Verify the CSRF token with
hash_equals()and stop if it fails - Check the honeypot field and any rate limit
- Trim and validate every field, collecting messages into an
$errorsarray - If there are errors, fall through and redisplay the form with escaped old values
- If there are none, save with a prepared statement, set a flash message, redirect, and
exit - Escape every value you print, using the same helper everywhere
- Hide the honeypot with CSS rather than
type="hidden". Some bots specifically skip hidden inputs, and a visually hidden text field looks like any other field to them. Give it an innocuous name such aswebsite— and mark ittabindex="-1"andautocomplete="off"so keyboard users and password managers never land in it.
