Project: A Guestbook

Time to put it all together. This final lesson walks through a complete, small web application: a guestbook where visitors sign with their name and a message. It has a front controller and router, a SQLite database accessed through PDO, templates with escaping, validation, CSRF protection, flash messages with Post/Redirect/Get, and a JSON endpoint — the same building blocks real PHP applications use.

What it does

RequestResult
GET /The guestbook: latest entries and a sign form
POST /signValidates and saves an entry, then redirects back with a "thank you" message
GET /api/entriesThe entries as JSON
anything elseA 404 page

Where each topic appears

FeatureUsed forLesson
Front controller and routingOne entry point, match on method and path53
PDO with prepared statementsStoring and reading entries safely57–58
Templates and output bufferingLayout plus page template52
EscapingEvery value printed into HTML47
ValidationName and message rules, errors shown next to the form47
Sessions, CSRF tokens, flash messagesProtecting the form and confirming success48, 59
Post/Redirect/GetNo duplicate entries on refresh46
JSONThe API endpoint55
Strict types, readonly classesTyped, immutable entries29, 41

Project layout

TXT
guestbook/
├── index.php            front controller: routes every request
├── src/
│   └── bootstrap.php    database, session and helper functions
├── templates/
│   ├── layout.php       the shared page frame
│   └── home.php         the guestbook page
└── storage/
    └── guestbook.sqlite created automatically

Run it from the guestbook folder with php -S localhost:8000 index.php and open http://localhost:8000/. (In production, the web server would send every request to index.php, and only it would be publicly reachable.)

src/bootstrap.php

Setup shared by every request: the database connection (creating the table on first run), the session, and small helpers for escaping, CSRF tokens, flash messages and rendering templates.

PHP src/bootstrap.php
<?php
declare(strict_types=1);

final readonly class Entry
{
    public function __construct(
        public int $id,
        public string $name,
        public string $message,
        public string $createdAt,
    ) {}
}

function db(): PDO
{
    static $pdo = null;
    if ($pdo === null) {
        @mkdir(__DIR__ . '/../storage', 0775, true);
        $pdo = new PDO('sqlite:' . __DIR__ . '/../storage/guestbook.sqlite', options: [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        ]);
        $pdo->exec('CREATE TABLE IF NOT EXISTS entries (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT NOT NULL,
            message TEXT NOT NULL,
            created_at TEXT NOT NULL
        )');
    }
    return $pdo;
}

/** @return list<Entry> */
function latestEntries(int $limit = 20): array
{
    $stmt = db()->prepare('SELECT * FROM entries ORDER BY id DESC LIMIT ?');
    $stmt->execute([$limit]);
    return array_map(
        fn(array $r) => new Entry((int) $r['id'], $r['name'], $r['message'], $r['created_at']),
        $stmt->fetchAll(),
    );
}

function addEntry(string $name, string $message): int
{
    db()->prepare('INSERT INTO entries (name, message, created_at) VALUES (?, ?, ?)')
        ->execute([$name, $message, gmdate('c')]);
    return (int) db()->lastInsertId();
}

/** @return array<string, string> field => error message */
function validateEntry(string $name, string $message): array
{
    $errors = [];
    if (mb_strlen($name) < 2 || mb_strlen($name) > 40) {
        $errors['name'] = 'Name must be 2–40 characters.';
    }
    if (mb_strlen($message) < 3 || mb_strlen($message) > 500) {
        $errors['message'] = 'Message must be 3–500 characters.';
    }
    return $errors;
}

function e(?string $value): string
{
    return htmlspecialchars($value ?? '', ENT_QUOTES, 'UTF-8');
}

function csrfToken(): string
{
    return $_SESSION['csrf'] ??= bin2hex(random_bytes(32));
}

function checkCsrf(): void
{
    if (!hash_equals(csrfToken(), (string) ($_POST['csrf'] ?? ''))) {
        http_response_code(403);
        exit('Invalid form submission. Please reload the page and try again.');
    }
}

function flash(?string $message = null): ?string
{
    if ($message !== null) {
        $_SESSION['flash'] = $message;
        return null;
    }
    $stored = $_SESSION['flash'] ?? null;
    unset($_SESSION['flash']);
    return $stored;
}

function render(string $template, array $data = []): string
{
    extract($data);
    ob_start();
    require __DIR__ . "/../templates/$template.php";
    return ob_get_clean();
}

function page(string $title, string $template, array $data = []): string
{
    return render('layout', ['title' => $title, 'content' => render($template, $data)]);
}

session_start(['cookie_httponly' => true, 'cookie_samesite' => 'Lax']);

templates/layout.php

PHP templates/layout.php
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title><?= e($title) ?> · Guestbook</title>
</head>
<body>
<main>
<?= $content ?>
</main>
</body>
</html>

templates/home.php

The page template only displays data it's given, escaping every value with e():

PHP templates/home.php
<h1>Guestbook</h1>
<?php if ($flash): ?>
<p class="flash"><?= e($flash) ?></p>
<?php endif; ?>
<form method="post" action="/sign">
  <input type="hidden" name="csrf" value="<?= e($csrf) ?>">
  <input name="name" value="<?= e($old['name'] ?? '') ?>"><?php if (isset($errors['name'])): ?> <span class="error"><?= e($errors['name']) ?></span><?php endif; ?>

  <textarea name="message"><?= e($old['message'] ?? '') ?></textarea><?php if (isset($errors['message'])): ?> <span class="error"><?= e($errors['message']) ?></span><?php endif; ?>

  <button>Sign</button>
</form>
<?php if ($entries === []): ?>
<p>No entries yet. Be the first!</p>
<?php else: ?>
<ol>
<?php foreach ($entries as $entry): ?>
  <li><strong><?= e($entry->name) ?></strong>: <?= nl2br(e($entry->message)) ?></li>
<?php endforeach; ?>
</ol>
<?php endif; ?>

index.php — the front controller

Every request arrives here. A match on the method and path picks the action; each action reads input, calls the functions from bootstrap.php, and returns a response:

PHP index.php
<?php
declare(strict_types=1);

require __DIR__ . '/src/bootstrap.php';

$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

echo match ([$method, $path]) {
    ['GET', '/'] => showGuestbook(),
    ['POST', '/sign'] => signGuestbook(),
    ['GET', '/api/entries'] => entriesJson(),
    default => notFound(),
};

function showGuestbook(array $errors = [], array $old = []): string
{
    return page('Sign our guestbook', 'home', [
        'entries' => latestEntries(),
        'flash' => flash(),
        'csrf' => csrfToken(),
        'errors' => $errors,
        'old' => $old,
    ]);
}

function signGuestbook(): string
{
    checkCsrf();
    $name = trim((string) ($_POST['name'] ?? ''));
    $message = trim((string) ($_POST['message'] ?? ''));

    $errors = validateEntry($name, $message);
    if ($errors !== []) {
        http_response_code(400);
        return showGuestbook($errors, ['name' => $name, 'message' => $message]);
    }

    addEntry($name, $message);
    flash("Thanks for signing, $name!");
    header('Location: /', true, 303);
    return '';
}

function entriesJson(): string
{
    header('Content-Type: application/json');
    $entries = array_map(fn(Entry $e) => ['id' => $e->id, 'name' => $e->name, 'message' => $e->message], latestEntries());
    return json_encode(['count' => count($entries), 'entries' => $entries], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}

function notFound(): string
{
    http_response_code(404);
    return page('Not found', 'home', ['entries' => [], 'flash' => 'That page does not exist.', 'csrf' => csrfToken(), 'errors' => [], 'old' => []]);
}

A visit, request by request

Here's a session with the guestbook, as a browser would make it. {{csrf}} stands for the hidden CSRF token from the form on the previous page, which a browser submits automatically. (The token is 64 random hexadecimal characters, different on every visit, so it's shown as … below.)

HTTP
GET /
POST /sign name=Ana&message=Lovely+site%21+Greetings+from+Lisbon.&csrf={{csrf}}
GET /
POST /sign name=B&message=Hi&csrf={{csrf}}
POST /sign name=Mallory&message=<script>alert(1)</script>&csrf=stolen
GET /api/entries
Responses
> GET /
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Sign our guestbook · Guestbook</title>
</head>
<body>
<main>
<h1>Guestbook</h1>
<form method="post" action="/sign">
  <input type="hidden" name="csrf" value="…">
  <input name="name" value="">
  <textarea name="message"></textarea>
  <button>Sign</button>
</form>
<p>No entries yet. Be the first!</p>
</main>
</body>
</html>
> POST /sign
< 303 See Other
< Location: /
> GET /
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Sign our guestbook · Guestbook</title>
</head>
<body>
<main>
<h1>Guestbook</h1>
<p class="flash">Thanks for signing, Ana!</p>
<form method="post" action="/sign">
  <input type="hidden" name="csrf" value="…">
  <input name="name" value="">
  <textarea name="message"></textarea>
  <button>Sign</button>
</form>
<ol>
  <li><strong>Ana</strong>: Lovely site! Greetings from Lisbon.</li>
</ol>
</main>
</body>
</html>
> POST /sign
< 400 Bad Request
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Sign our guestbook · Guestbook</title>
</head>
<body>
<main>
<h1>Guestbook</h1>
<form method="post" action="/sign">
  <input type="hidden" name="csrf" value="…">
  <input name="name" value="B"> <span class="error">Name must be 2–40 characters.</span>
  <textarea name="message">Hi</textarea> <span class="error">Message must be 3–500 characters.</span>
  <button>Sign</button>
</form>
<ol>
  <li><strong>Ana</strong>: Lovely site! Greetings from Lisbon.</li>
</ol>
</main>
</body>
</html>
> POST /sign
< 403 Forbidden
Invalid form submission. Please reload the page and try again.
> GET /api/entries
{
    "count": 1,
    "entries": [
        {
            "id": 1,
            "name": "Ana",
            "message": "Lovely site! Greetings from Lisbon."
        }
    ]
}

How it works

One entry point

Because every request goes through index.php, shared setup — strict types, the database, the session — happens in one place, and URLs are clean (/sign, not /sign.php). Matching on [$method, $path] means a GET to /sign falls through to the 404 page instead of trying to save an entry.

Safe by construction

  • SQL injection is impossible because every query with outside data is a prepared statement.
  • XSS is prevented because templates print values only through e(). Try submitting a message containing <script> with a valid token: it's displayed as text.
  • CSRF is blocked by the token: Mallory's forged request was rejected with 403 Forbidden before anything else ran.
  • The session cookie is HttpOnly and SameSite=Lax.

Friendly forms

Invalid input re-renders the page with a 400 status, error messages next to the fields and the user's text preserved. Valid input is saved, a flash message is stored, and a 303 See Other redirect sends the browser back to / with a GET — so refreshing the page can't post the entry twice, and the "thank you" appears exactly once.

Logic separate from presentation

Functions in bootstrap.php know nothing about HTML, and templates know nothing about the database. The same latestEntries() feeds both the HTML page and the JSON API.

Challenges

Extend the guestbook to practise what you've learned. Each challenge points to the lesson that helps most.

  1. Show each entry's date as "3 hours ago" using created_at (lesson 56).
  2. Add pagination with ?page=2, using LIMIT and OFFSET in a prepared statement (lesson 58).
  3. Allow Markdown in messages with league/commonmark, keeping raw HTML escaped (lesson 60).
  4. Add a simple admin login with password_hash, and a CSRF-protected delete button for each entry (lessons 48 and 59).
  5. Limit each visitor to one entry per minute by storing the last post time in the session (lesson 48).
  6. Move the functions into classes — EntryRepository, Validator, View — under a src/ PSR-4 namespace, loaded with Composer (lessons 43 and 60).
  7. Write PHPUnit tests for validateEntry() and for the repository using an in-memory SQLite database (lesson 61).
  8. Accept new entries as JSON with POST /api/entries, returning 201 Created (lesson 55).

Where to go next

You now know modern PHP — the language, the web fundamentals and the tools professionals use. Natural next steps:

  • A framework: Laravel or Symfony. You'll recognise routing, controllers, templates, validation, CSRF tokens and repositories from this course — frameworks provide polished, well-tested versions of each.
  • Databases in depth with our SQL course and MySQL course.
  • Front-end skills with HTML, CSS and JavaScript.
  • Version control for your projects with Git.

Before moving on, test yourself with the quiz and exercises. Congratulations on finishing the course!