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
| Request | Result |
|---|---|
GET / | The guestbook: latest entries and a sign form |
POST /sign | Validates and saves an entry, then redirects back with a "thank you" message |
GET /api/entries | The entries as JSON |
| anything else | A 404 page |
Where each topic appears
| Feature | Used for | Lesson |
|---|---|---|
| Front controller and routing | One entry point, match on method and path | 53 |
| PDO with prepared statements | Storing and reading entries safely | 57–58 |
| Templates and output buffering | Layout plus page template | 52 |
| Escaping | Every value printed into HTML | 47 |
| Validation | Name and message rules, errors shown next to the form | 47 |
| Sessions, CSRF tokens, flash messages | Protecting the form and confirming success | 48, 59 |
| Post/Redirect/Get | No duplicate entries on refresh | 46 |
| JSON | The API endpoint | 55 |
| Strict types, readonly classes | Typed, immutable entries | 29, 41 |
Project layout
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 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
<!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():
<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 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.)
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
> 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 Forbiddenbefore anything else ran. - The session cookie is
HttpOnlyandSameSite=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.
- Show each entry's date as "3 hours ago" using
created_at(lesson 56). - Add pagination with
?page=2, usingLIMITandOFFSETin a prepared statement (lesson 58). - Allow Markdown in messages with
league/commonmark, keeping raw HTML escaped (lesson 60). - Add a simple admin login with
password_hash, and a CSRF-protected delete button for each entry (lessons 48 and 59). - Limit each visitor to one entry per minute by storing the last post time in the session (lesson 48).
- Move the functions into classes —
EntryRepository,Validator,View— under asrc/PSR-4 namespace, loaded with Composer (lessons 43 and 60). - Write PHPUnit tests for
validateEntry()and for the repository using an in-memory SQLite database (lesson 61). - Accept new entries as JSON with
POST /api/entries, returning201 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!