First PHP Projects

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 example
<?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 example
<?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:

PHP example
// 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 example
<?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():

PHP example
// 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:

PHP example
// 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:

PHP example
// 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:

PHP example
// 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 GET and POST; return 405 with an Allow header 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 303 after 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 example
<?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:

PHP example
// 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:

PHP example
// 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.