Contact Form Handler
This project moves the application boundary from a terminal to HTTP. Build a page that renders on GET, validates on POST, rejects invalid CSRF tokens, stores accepted messages outside the public directory, and redirects after success.
public/contact.php
src/ContactForm.php
storage/contact-messages.ndjson
templates/contact.php
Create storage/ before running the project and make it writable by the PHP process. Do not place stored messages under public/.
Normalize Shape Before Values
A browser field can arrive as an array when a request uses a name such as name[]. Reject that shape without casting it to the string Array or emitting a warning. Put this code in src/ContactForm.php:
<?php
declare(strict_types=1);
function textField(array $input, string $key): string
{
$value = $input[$key] ?? '';
return is_string($value) ? trim($value) : '';
}
function validateContact(array $input): array
{
$values = [
'name' => textField($input, 'name'),
'email' => textField($input, 'email'),
'message' => textField($input, 'message'),
];
$errors = [];
if ($values['name'] === '' || strlen($values['name']) > 100) {
$errors['name'] = 'Enter a name up to 100 bytes.';
}
if (strlen($values['email']) > 254
|| filter_var($values['email'], FILTER_VALIDATE_EMAIL) === false) {
$errors['email'] = 'Enter a valid email address.';
}
if ($values['message'] === '' || strlen($values['message']) > 5_000) {
$errors['message'] = 'Enter a message up to 5000 bytes.';
}
return [$values, $errors];
}
function storeContact(array $values, string $path): void
{
$record = [
'received_at' => (new DateTimeImmutable())->format(DATE_ATOM),
'name' => $values['name'],
'email' => $values['email'],
'message' => $values['message'],
];
$line = json_encode($record, JSON_THROW_ON_ERROR) . PHP_EOL;
if (file_put_contents($path, $line, FILE_APPEND | LOCK_EX) === false) {
throw new RuntimeException('The message could not be stored.');
}
}
The byte limits are deliberate and match strlen(). A project that promises character limits should use and require the mbstring extension instead.
Configure The Session Before Starting It
The request handler must configure cookies before session_start(). In public/contact.php:
<?php
declare(strict_types=1);
// no-execute: requires an HTTP request, project files, template, and writable storage.
require dirname(__DIR__) . '/src/ContactForm.php';
ini_set('session.use_strict_mode', '1');
$isHttps = ($_SERVER['HTTPS'] ?? '') !== '' && ($_SERVER['HTTPS'] ?? '') !== 'off';
session_set_cookie_params([
'path' => '/',
'secure' => $isHttps,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
$values = ['name' => '', 'email' => '', 'message' => ''];
$errors = [];
$flash = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if (!in_array($method, ['GET', 'POST'], true)) {
header('Allow: GET, POST');
http_response_code(405);
exit('Method Not Allowed');
}
if ($method === 'POST') {
$submittedToken = $_POST['_csrf'] ?? null;
if (!is_string($submittedToken) || !hash_equals($_SESSION['csrf'], $submittedToken)) {
http_response_code(403);
exit('Invalid request token.');
}
[$values, $errors] = validateContact($_POST);
if ($errors === []) {
storeContact($values, dirname(__DIR__) . '/storage/contact-messages.ndjson');
$_SESSION['csrf'] = bin2hex(random_bytes(32));
$_SESSION['flash'] = 'Message accepted.';
header('Location: /contact.php', true, 303);
exit;
}
}
require dirname(__DIR__) . '/templates/contact.php';
A 303 See Other tells the browser to follow the successful POST with GET, so refresh does not repeat the submission. Rotating the token after acceptance prevents reuse of the submitted token.
Escape Only When Rendering
The template keeps validation and HTML escaping separate:
<?php
declare(strict_types=1);
// no-execute: template requires values, errors, flash, and session state from the handler.
function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
?>
<?php if (is_string($flash)): ?>
<p role="status"><?= e($flash) ?></p>
<?php endif; ?>
<form method="post" action="/contact.php">
<input type="hidden" name="_csrf" value="<?= e($_SESSION['csrf']) ?>">
<label>Name <input name="name" maxlength="100" value="<?= e($values['name']) ?>"></label>
<?php if (isset($errors['name'])): ?><p><?= e($errors['name']) ?></p><?php endif; ?>
<label>Email <input type="email" name="email" maxlength="254" value="<?= e($values['email']) ?>"></label>
<?php if (isset($errors['email'])): ?><p><?= e($errors['email']) ?></p><?php endif; ?>
<label>Message <textarea name="message" maxlength="5000"><?= e($values['message']) ?></textarea></label>
<?php if (isset($errors['message'])): ?><p><?= e($errors['message']) ?></p><?php endif; ?>
<button type="submit">Send</button>
</form>
Browser validation improves feedback but does not replace the PHP checks. Escaping happens in the HTML context, including the hidden token and preserved values.
Verify The HTTP Lifecycle
Run the document root with php -S localhost:8000 -t public, then verify:
GETcreates a session and renders an empty form.- Empty and array-shaped fields produce controlled errors without PHP warnings.
- Missing or incorrect CSRF tokens return
403and store nothing. - A valid submission appends one locked NDJSON record and returns
303. - The redirected
GETconsumes the flash message, and refresh does not append again. - HTML characters are stored as data and escaped only when rendered.
Practice
Practice: Build A Contact Form Handler
Implement the complete contact form flow from the lesson.
Requirements
- Configure strict session handling and cookie flags before
session_start(). - Render name, email, message, and a session-backed CSRF token.
- Accept only
GETandPOST; return405with anAllowheader otherwise. - Reject array-shaped fields without warnings or string casts.
- Validate required values, email format, and explicit byte limits in PHP.
- Escape preserved values, errors, flash text, and the token at render time.
- Store accepted messages outside
public/with an exclusive append lock. - Rotate the CSRF token and redirect with
303after success. - Consume the flash message once on the redirected request.
- Do not log or expose full message bodies unnecessarily.
Verify GET, invalid fields, array-shaped fields, missing token, bad token, accepted POST, redirected GET, and refresh-after-success behavior.
Show solution
<?php
declare(strict_types=1);
// no-execute: requires the lesson project, HTTP request, template, and writable storage.
require dirname(__DIR__) . '/src/ContactForm.php';
ini_set('session.use_strict_mode', '1');
$isHttps = ($_SERVER['HTTPS'] ?? '') !== '' && ($_SERVER['HTTPS'] ?? '') !== 'off';
session_set_cookie_params([
'path' => '/',
'secure' => $isHttps,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
$values = ['name' => '', 'email' => '', 'message' => ''];
$errors = [];
$flash = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if (!in_array($method, ['GET', 'POST'], true)) {
header('Allow: GET, POST');
http_response_code(405);
exit('Method Not Allowed');
}
if ($method === 'POST') {
$submittedToken = $_POST['_csrf'] ?? null;
if (!is_string($submittedToken) || !hash_equals($_SESSION['csrf'], $submittedToken)) {
http_response_code(403);
exit('Invalid request token.');
}
[$values, $errors] = validateContact($_POST);
if ($errors === []) {
storeContact($values, dirname(__DIR__) . '/storage/contact-messages.ndjson');
$_SESSION['csrf'] = bin2hex(random_bytes(32));
$_SESSION['flash'] = 'Message accepted.';
header('Location: /contact.php', true, 303);
exit;
}
}
require dirname(__DIR__) . '/templates/contact.php';
Use the complete escaped template from the lesson. Run the app with:
mkdir -p storage
php -S localhost:8000 -t public
After a valid POST, inspect the response status before following the redirect and confirm exactly one line was appended. Submit name[]=unexpected with a client such as curl; it must produce a field error rather than an Array to string conversion warning.