27. PHP Practical Project: Guestbook
What you will build
A small guestbook: visitors leave a name and a message, and the latest messages are shown below the form. It uses almost everything in this course: forms, validation, sessions, a CSRF token, file storage, escaping, includes, and the Post / Redirect / Get pattern.
Practise only on your local site. This project stores messages in a plain file for simplicity. A real site would use a database (and, on WordPress, a plugin built for the job). Never put a form like this on a live site without extra protection such as spam checks and rate limiting.
The plan
Folder structure
practice/guestbook/
index.php (form, validation, list)
includes/
functions.php (helper functions)
data/
messages.json (created automatically)
- functions.php: small helpers for escaping, tokens, reading, and saving.
- index.php: handles the form, then draws the page.
- After a successful save the browser is redirected, so refreshing never sends the form twice.
Step 1: The helper functions
PHP code: includes/functions.php
<?php
declare(strict_types=1);
function e(string $text): string
{
return htmlspecialchars($text, ENT_QUOTES, "UTF-8");
}
function postText(string $key): string
{
$value = $_POST[$key] ?? "";
return is_string($value) ? trim($value) : "";
}
function csrfToken(): string
{
if (empty($_SESSION["csrf"])) {
$_SESSION["csrf"] = bin2hex(random_bytes(32));
}
return $_SESSION["csrf"];
}
function csrfValid(string $token): bool
{
return isset($_SESSION["csrf"]) && hash_equals($_SESSION["csrf"], $token);
}
function loadMessages(string $path): array
{
if (!is_file($path)) {
return [];
}
$data = json_decode((string) file_get_contents($path), true);
return is_array($data) ? $data : [];
}
function saveMessages(string $path, array $messages): bool
{
$dir = dirname($path);
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
return false;
}
$json = json_encode($messages, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
return $json !== false && file_put_contents($path, $json, LOCK_EX) !== false;
}
postText()returns an empty string if a field is missing or is an array, so later code always gets text.csrfToken()andcsrfValid()are the token functions from the Security lesson.saveMessages()creates the folder if needed, locks the file while writing, and reports success or failure.
Step 2: Handle the form
PHP code: index.php (the top, before any HTML)
<?php
declare(strict_types=1);
session_start();
require_once __DIR__ . "/includes/functions.php";
const MAX_NAME = 40;
const MAX_MESSAGE = 500;
const KEEP_MESSAGES = 50;
$file = __DIR__ . "/data/messages.json";
$errors = [];
$name = "";
$message = "";
if ($_SERVER["REQUEST_METHOD"] === "POST") {
if (!csrfValid(postText("csrf"))) {
http_response_code(400);
exit("Invalid request. Please go back and try again.");
}
$name = postText("name");
$message = postText("message");
if ($name === "") {
$errors["name"] = "Please enter your name.";
} elseif (mb_strlen($name, "UTF-8") > MAX_NAME) {
$errors["name"] = "Your name must be " . MAX_NAME . " characters or fewer.";
}
if ($message === "") {
$errors["message"] = "Please write a message.";
} elseif (mb_strlen($message, "UTF-8") > MAX_MESSAGE) {
$errors["message"] = "Your message must be " . MAX_MESSAGE . " characters or fewer.";
}
if (!$errors) {
$all = loadMessages($file);
array_unshift($all, [
"name" => $name,
"message" => $message,
"time" => date("Y-m-d H:i"),
]);
$all = array_slice($all, 0, KEEP_MESSAGES);
if (saveMessages($file, $all)) {
$_SESSION["flash"] = "Thank you! Your message was added.";
header("Location: index.php");
exit;
}
$errors["form"] = "Sorry, your message could not be saved.";
}
}
$flash = $_SESSION["flash"] ?? "";
unset($_SESSION["flash"]);
$messages = loadMessages($file);
- The token is checked first. A request without a valid token stops immediately.
- Both fields are trimmed and checked, with messages stored under each field’s name.
- If everything is valid, the new message is put at the top of the list, and the list is cut to the latest 50.
- After saving, a one-time “flash” message is stored in the session, and the browser is redirected. The next page load shows the message once and removes it.
Step 3: Draw the page
PHP code: index.php (the bottom, the HTML part)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Guestbook</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 0 auto; padding: 16px; color: #17345c; }
label { display: block; margin-top: 12px; font-weight: 600; }
input[type="text"], textarea { width: 100%; padding: 8px; border: 1px solid #cfe6ff; border-radius: 6px; font: inherit; box-sizing: border-box; }
button { margin-top: 12px; padding: 8px 18px; color: #fff; background: #0d6efd; border: 0; border-radius: 6px; font: inherit; cursor: pointer; }
.error { color: #b02a37; margin: 4px 0 0; }
.ok { color: #146c43; font-weight: 600; }
.entry { padding: 10px 0; border-bottom: 1px solid #cfe6ff; }
.meta { font-size: 0.85rem; color: #4f6b8f; }
</style>
</head>
<body>
<h1>Guestbook</h1>
<?php if ($flash !== ""): ?>
<p class="ok" role="status"><?= e($flash) ?></p>
<?php endif; ?>
<?php if (isset($errors["form"])): ?>
<p class="error" role="alert"><?= e($errors["form"]) ?></p>
<?php endif; ?>
<form method="post" novalidate>
<input type="hidden" name="csrf" value="<?= e(csrfToken()) ?>">
<label for="name">Name</label>
<input type="text" id="name" name="name" value="<?= e($name) ?>">
<?php if (isset($errors["name"])): ?>
<p class="error"><?= e($errors["name"]) ?></p>
<?php endif; ?>
<label for="message">Message</label>
<textarea id="message" name="message" rows="4"><?= e($message) ?></textarea>
<?php if (isset($errors["message"])): ?>
<p class="error"><?= e($errors["message"]) ?></p>
<?php endif; ?>
<button type="submit">Sign the guestbook</button>
</form>
<h2>Latest messages</h2>
<?php if (!$messages): ?>
<p>No messages yet. Be the first!</p>
<?php endif; ?>
<?php foreach ($messages as $entry): ?>
<div class="entry">
<strong><?= e((string) ($entry["name"] ?? "")) ?></strong>
<span class="meta"><?= e((string) ($entry["time"] ?? "")) ?></span>
<p><?= nl2br(e((string) ($entry["message"] ?? ""))) ?></p>
</div>
<?php endforeach; ?>
</body>
</html>
- Every printed value goes through
e(). The message is escaped first, thennl2br()turns line breaks into<br>. In that order, visitor text cannot add HTML. - The values typed by the visitor go back into the form, so nobody has to retype after an error.
- The hidden
csrffield carries the token.
What you should see
The behaviour, step by step (not a live demo)
- Opening
guestbook/index.phpshows the form and “No messages yet. Be the first!” - Pressing the button with empty fields shows “Please enter your name.” and “Please write a message.”
- Entering Sam and Hello! redirects back and shows “Thank you! Your message was added.” with Sam’s message listed.
- Refreshing the page does not add the message again, and the thank-you line disappears.
- Typing
<b>bold</b>as a message shows those characters as text, not bold.
Check the security
- XSS: every value is escaped when printed.
- CSRF: a POST without the right token is refused. Try removing the hidden field in your browser’s developer tools and submitting.
- Double submit: the redirect prevents repeats on refresh.
- Input size: names and messages have length limits, and only 50 messages are kept.
- Still missing: spam protection, rate limiting, moderation, and keeping
data/out of the public folder. Anyone who knows the address could downloadmessages.json. That is acceptable for practice, but not for a live site.
Challenges
- Add an optional email field, validated with
filter_var(), and store it (but do not show it). - Add a simple honeypot field, hidden with CSS, and refuse submissions where it is filled in.
- Limit each visitor to one message per minute using a session timestamp.
- Show messages ten per page, using
?page=2andarray_slice(). - Replace the file with a database table and prepared statements.
- Move the helper functions into a class in its own file.
Common mistakes
- Output before
session_start()orheader(), giving “headers already sent”. - Forgetting
exitafter the redirect. - Printing a value without
e(). - The
datafolder is not writable, so saving fails. Check the folder permissions. - Moving files and breaking the
require_oncepath.
Summary
- You built a complete form-driven page with validation, a CSRF token, file storage, and safe output.
- The structure is: validate and process at the top, redirect on success, and draw the page below.
- You are now ready to apply these ideas to WordPress themes and plugins, which use the same building blocks.