First PHP Projects

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

  1. GET creates a session and renders an empty form.
  2. Empty and array-shaped fields produce controlled errors without PHP warnings.
  3. Missing or incorrect CSRF tokens return 403 and store nothing.
  4. A valid submission appends one locked NDJSON record and returns 303.
  5. The redirected GET consumes the flash message, and refresh does not append again.
  6. 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 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
PHP example
<?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.