Contact Form Handler
public/contact.php
src/ContactForm.php
storage/contact-messages.ndjson
templates/contact.php
Create storage/ outside public/ and make it writable by PHP.
Reject The Wrong Input Shape
A field named name[] arrives as an array. Casting it produces warnings and the string Array. Start with a scalar helper:
<?php
declare(strict_types=1);
function textField(array $input, string $key): ?string
{
$value = $input[$key] ?? null;
return is_string($value) ? trim($value) : null;
}
null now means missing or wrong-shaped input. An empty string remains distinguishable and can receive a required-field error.
Validate One Rule At A Time
Keep reusable byte-bound checks small:
<?php
declare(strict_types=1);
function requiredText(?string $value, int $maximumBytes): ?string
{
if ($value === null || $value === '' || strlen($value) > $maximumBytes) {
return null;
}
return $value;
}
Compose those checks into the contact result:
// Partial: inside validateContact(array $input).
$values = [
'name' => textField($input, 'name') ?? '',
'email' => textField($input, 'email') ?? '',
'message' => textField($input, 'message') ?? '',
];
$errors = [];
if (requiredText(textField($input, 'name'), 100) === null) {
$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 (requiredText(textField($input, 'message'), 5_000) === null) {
$errors['message'] = 'Enter a message up to 5000 bytes.';
}
return [$values, $errors];
The limits are bytes because the code uses strlen(). A character-count promise would require mbstring and mb_strlen().
Persist Through One Function
This project stores newline-delimited JSON so persistence is visible without introducing PDO again:
<?php
declare(strict_types=1);
function storeContact(array $values, string $path): void
{
$record = ['received_at' => (new DateTimeImmutable())->format(DATE_ATOM)] + $values;
$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 exclusive append lock prevents two local requests from interleaving one line. It does not turn a file into a multi-server database.
Configure The Session Before Starting It
Cookie attributes must be chosen before session_start():
// Partial: start of public/contact.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));
Production must know whether HTTPS was established directly or by a trusted proxy. Do not trust arbitrary forwarded headers.
Verify The Request Before Work
Accept only GET and POST. On POST, compare a scalar token before validating or storing fields:
// Partial: request checks in public/contact.php.
$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') {
$token = $_POST['_csrf'] ?? null;
if (!is_string($token) || !hash_equals($_SESSION['csrf'], $token)) {
http_response_code(403);
exit('Invalid request token.');
}
}
CSRF failure stores nothing. Browser validation is useful feedback, but these PHP checks remain authoritative.
Redirect Only After Persistence
The accepted branch is short because earlier steps own their decisions:
// Partial: inside the verified POST branch.
[$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;
}
A 303 changes the follow-up to GET, so refresh does not repeat the write. Rotate the accepted token and consume the flash message once on that GET.
Escape At The Last Moment
The template receives prepared $values, $errors, $flash, and the token. Escape each value where it becomes HTML:
// no-execute: partial template requires prepared view data and e().
<input type="hidden" name="_csrf" value="<?= e($_SESSION['csrf']) ?>">
<label>
Email
<input type="email" name="email" value="<?= e($values['email']) ?>">
</label>
<?php if (isset($errors['email'])): ?>
<p><?= e($errors['email']) ?></p>
<?php endif; ?>
Validation decides whether data is acceptable; htmlspecialchars() prevents accepted or preserved text from becoming HTML. Test empty, array-shaped, overlong, wrong-token, accepted, redirected, and refreshed requests.
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
Use textField(), requiredText(), validateContact(), and storeContact() from the lesson. Build public/contact.php in three stages.
Bootstrap The Request
<?php
declare(strict_types=1);
// no-execute: first section of the HTTP entry file; later sections complete it.
require dirname(__DIR__) . '/src/ContactForm.php';
ini_set('session.use_strict_mode', '1');
session_set_cookie_params([
'path' => '/',
'secure' => ($_SERVER['HTTPS'] ?? '') === 'on',
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
$values = ['name' => '', 'email' => '', 'message' => ''];
$errors = [];
$flash = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);
Enforce Method And CSRF
Append the request gate:
// Partial: continues public/contact.php.
$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') {
$token = $_POST['_csrf'] ?? null;
if (!is_string($token) || !hash_equals($_SESSION['csrf'], $token)) {
http_response_code(403);
exit('Invalid request token.');
}
Validate, Persist, And Render
Finish the POST branch and load the template:
// Partial: final section of public/contact.php.
[$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 escaped form pattern from the article for all three fields and the hidden token. Submit name[]=unexpected and confirm it produces a field error without a PHP warning. A valid POST must append exactly one line, return 303, show one flash message, and remain a single stored line after refresh.