What you will build
A guestbook: visitors leave their name and a short message, and the page lists the newest messages, ten at a time. It is small, but it has everything a real feature needs:
- a database table and a connection shared by all pages,
- a form with server-side validation and helpful error messages,
- CSRF protection and output escaping,
- Post/Redirect/Get with a one-time "flash" message,
- pagination.
Every piece comes from an earlier lesson, linked as it appears. The project uses SQLite, so it runs anywhere; for MySQL only the DSN in db.php changes. Create an empty folder and add the files below.
1. The database: db.php
db.php connects, creates the table the first time, and returns the PDO object, as in Connecting to a database with PDO:
<?php
declare(strict_types=1);
$pdo = new PDO('sqlite:' . __DIR__ . '/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;For MySQL, the table would use INT AUTO_INCREMENT PRIMARY KEY, VARCHAR(60), TEXT and DATETIME, with CHARSET=utf8mb4.
2. Helpers: helpers.php
Escaping, CSRF tokens and flash messages, from Mixing PHP and HTML, Web security: XSS, CSRF and SQL injection and Sessions:
<?php
declare(strict_types=1);
function e(?string $value): string
{
return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function csrfToken(): string
{
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
return $_SESSION['csrf'];
}
function csrfValid(mixed $sent): bool
{
$expected = $_SESSION['csrf'] ?? '';
return $expected !== '' && is_string($sent) && hash_equals($expected, $sent);
}
function flash(?string $message = null): ?string
{
if ($message !== null) {
$_SESSION['flash'] = $message;
return null;
}
$current = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);
return $current;
}
/** @return array<string, string> field => error message */
function validateEntry(string $name, string $message): array
{
$errors = [];
if ($name === '') {
$errors['name'] = 'Please enter your name.';
} elseif (mb_strlen($name) > 60) {
$errors['name'] = 'Name must be 60 characters or fewer.';
}
if (mb_strlen($message) < 3) {
$errors['message'] = 'Please write at least 3 characters.';
} elseif (mb_strlen($message) > 500) {
$errors['message'] = 'Messages are limited to 500 characters.';
}
return $errors;
}flash() stores a message when given one, and returns and removes it when called without one, so each message shows exactly once.
3. The page: index.php
The page has two halves. The top handles a POST: check the token, validate, save, redirect. The bottom renders the page with the flash message, the form and the entries:
<?php
declare(strict_types=1);
session_start();
$pdo = require __DIR__ . '/db.php';
require __DIR__ . '/helpers.php';
const PER_PAGE = 10;
$errors = [];
$old = ['name' => '', 'message' => ''];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!csrfValid($_POST['csrf'] ?? null)) {
http_response_code(403);
exit('The form expired. Please reload the page and try again.');
}
$old['name'] = trim((string) ($_POST['name'] ?? ''));
$old['message'] = trim((string) ($_POST['message'] ?? ''));
$errors = validateEntry($old['name'], $old['message']);
if (!$errors) {
$insert = $pdo->prepare('INSERT INTO entries (name, message, created_at) VALUES (?, ?, ?)');
$insert->execute([$old['name'], $old['message'], (new DateTimeImmutable('2024-06-03 10:15'))->format('Y-m-d H:i:s')]);
flash('Thanks for signing the guestbook, ' . $old['name'] . '!');
header('Location: /index.php', true, 303);
exit;
}
}
$total = (int) $pdo->query('SELECT COUNT(*) FROM entries')->fetchColumn();
$pages = max(1, (int) ceil($total / PER_PAGE));
$page = filter_var($_GET['page'] ?? 1, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => $pages]]) ?: 1;
$select = $pdo->prepare('SELECT name, message, created_at FROM entries ORDER BY id DESC LIMIT ? OFFSET ?');
$select->bindValue(1, PER_PAGE, PDO::PARAM_INT);
$select->bindValue(2, ($page - 1) * PER_PAGE, PDO::PARAM_INT);
$select->execute();
$entries = $select->fetchAll();
$notice = flash();
?>
<h1>Guestbook</h1>
<?php if ($notice): ?>
<p class="notice"><?= e($notice) ?></p>
<?php endif; ?>
<form method="post" action="/index.php">
<input type="hidden" name="csrf" value="<?= e(csrfToken()) ?>">
<label>Name <input name="name" maxlength="60" value="<?= e($old['name']) ?>"></label>
<?php if (isset($errors['name'])): ?>
<p class="error"><?= e($errors['name']) ?></p>
<?php endif; ?>
<label>Message <textarea name="message" maxlength="500"><?= e($old['message']) ?></textarea></label>
<?php if (isset($errors['message'])): ?>
<p class="error"><?= e($errors['message']) ?></p>
<?php endif; ?>
<button>Sign the guestbook</button>
</form>
<p><?= $total ?> <?= $total === 1 ? 'entry' : 'entries' ?></p>
<?php foreach ($entries as $entry): ?>
<article>
<h2><?= e($entry['name']) ?></h2>
<p><?= nl2br(e($entry['message'])) ?></p>
<time datetime="<?= e($entry['created_at']) ?>"><?= e((new DateTimeImmutable($entry['created_at']))->format('j M Y, H:i')) ?></time>
</article>
<?php endforeach; ?>
<?php if ($pages > 1): ?>
<nav>Page <?= $page ?> of <?= $pages ?></nav>
<?php endif; ?>Real code would use new DateTimeImmutable() (now) for created_at; a fixed time keeps the output below repeatable. LIMIT and OFFSET are bound with PDO::PARAM_INT, because they must be integers, and the page number is validated against the real number of pages first. nl2br() keeps the line breaks people type, after escaping.
Run it with php -S localhost:8000 in the folder and open http://localhost:8000/.
4. Trying it out
The first visit shows an empty guestbook. The form's CSRF token is random; it is shown as {{csrf}}:
GET /index.php<h1>Guestbook</h1>
<form method="post" action="/index.php">
<input type="hidden" name="csrf" value="{{csrf}}">
<label>Name <input name="name" maxlength="60" value=""></label>
<label>Message <textarea name="message" maxlength="500"></textarea></label>
<button>Sign the guestbook</button>
</form>
<p>0 entries</p>A submission with an empty name and a one-letter message is rejected, and what the visitor typed is kept:
POST /index.php
Content-Type: application/x-www-form-urlencoded
csrf={{csrf}}&name=&message=x<h1>Guestbook</h1>
<form method="post" action="/index.php">
<input type="hidden" name="csrf" value="{{csrf}}">
<label>Name <input name="name" maxlength="60" value=""></label>
<p class="error">Please enter your name.</p>
<label>Message <textarea name="message" maxlength="500">x</textarea></label>
<p class="error">Please write at least 3 characters.</p>
<button>Sign the guestbook</button>
</form>
<p>0 entries</p>A valid message is saved, and the server answers with a redirect:
POST /index.php
Content-Type: application/x-www-form-urlencoded
csrf={{csrf}}&name=Asha&message=Lovely+mugs%21%0AWill+order+again+%3Cb%3Esoon%3C%2Fb%3E.HTTP/1.1 303 See Other
Location: /index.phpThe browser follows the redirect and shows the flash message and the new entry. The <b> tag typed into the message is printed as text, not as HTML:
GET /index.php<h1>Guestbook</h1>
<p class="notice">Thanks for signing the guestbook, Asha!</p>
<form method="post" action="/index.php">
<input type="hidden" name="csrf" value="{{csrf}}">
<label>Name <input name="name" maxlength="60" value=""></label>
<label>Message <textarea name="message" maxlength="500"></textarea></label>
<button>Sign the guestbook</button>
</form>
<p>1 entry</p>
<article>
<h2>Asha</h2>
<p>Lovely mugs!<br />
Will order again <b>soon</b>.</p>
<time datetime="2024-06-03 10:15:00">3 Jun 2024, 10:15</time>
</article>Reloading that page is harmless, and the flash message is gone on the next view. Finally, a forged post from another site, without the token, is refused:
POST /index.php
Content-Type: application/x-www-form-urlencoded
name=Spammer&message=Buy+cheap+stuffHTTP/1.1 403 Forbidden
The form expired. Please reload the page and try again.5. Ideas to extend it
Each of these practises a lesson from the course:
- An admin page behind a login (Sessions, Storing passwords safely) that can delete entries with a POST form and a CSRF token.
- Previous and next page links using
?page=, keeping the validation of the page number. - A JSON feed of the latest entries at
feed.php(JSON: encoding and decoding data). - Rate limiting: store the time of each visitor's last post in the session and refuse a new one within 30 seconds.
- Classes: move the database code into an
EntryRepositoryclass withlatest(),count()andadd()methods, autoloaded with Composer (Namespaces, autoloading and Composer). - MySQL: change the DSN in
db.php, create the table in MySQL, and check that everything else works unchanged.
Common mistakes
- Printing an entry without e(), which turns the guestbook into an XSS hole.
- Skipping the redirect, so a refresh posts the message twice.
- Putting the page number straight into the SQL. Validate it and bind it as an integer.
- Forgetting the CSRF field in a new form you add later.
Exercise
Add the previous and next links. Below the entries, print a "Newer" link to ?page=N-1 when the page is above 1, and an "Older" link to ?page=N+1 when there are more pages. Write only the template part that replaces the <nav> block.
Show one possible answer
<?php if ($pages > 1): ?>
<nav>
<?php if ($page > 1): ?>
<a href="/index.php?page=<?= $page - 1 ?>">Newer</a>
<?php endif; ?>
Page <?= $page ?> of <?= $pages ?>
<?php if ($page < $pages): ?>
<a href="/index.php?page=<?= $page + 1 ?>">Older</a>
<?php endif; ?>
</nav>
<?php endif; ?>$page and $pages are integers you calculated yourself, so they are safe to print without escaping, but e() would do no harm either.
Key points
- Split the app into small files: database setup, helpers (escaping and CSRF), and the page itself.
- Handle the POST at the top: check the CSRF token, validate, insert with a prepared statement, set a flash message, redirect.
- Show the page below: flash message, form with old values and errors, and the newest entries, escaped and paginated.
- Every technique from the course appears: prepared statements, htmlspecialchars, sessions, PRG, filter_var and DateTimeImmutable.
Check yourself
Final exam and certificate
Finish all 54 lessons to unlock a 15-question exam on PHP. Pass it and you get a certificate with a public link you can share.
Sign in to take the exam