Final Project: Event Sign-Up Sheet
Every lesson gave you one tool. This project hands you the whole toolbox and a real job to do.
Before the project, one more look back.
At the start of this track, your first script was two lines: <?php and an echo. You weren't sure why the semicolon mattered, or why '$name' printed a dollar sign.
Look at what you know now. You know how a request travels from a browser to PHP and back, and why a variable forgets its value between visits. You can store values, juggle types without getting juggled, make decisions, and repeat work. You can write functions with typed parameters, split code across files, and keep lists and records in arrays. You can read forms, check every value, escape everything you show, and remember visitors with sessions. You can model real things with classes, save data to files, JSON, and a database, and handle problems with exceptions instead of crashing.
There were surely moments when nothing worked: a parse error pointing at the wrong line, an "Undefined variable" for a variable you were sure existed, a "headers already sent" that made no sense. You read the clues, found the cause, and fixed it. That's the real skill, and you have it now.
I'm genuinely proud of you. Let's build something.
The project
You'll build an Event Sign-Up Sheet: a small web app where people sign up for the Coding Club Hackathon. It's one page that:
- Shows the event name and how many of its 5 spots are left.
- Has a sign-up form for a name, an email, a shirt size, and an optional note.
- Checks every answer, and shows friendly messages next to the fields that need fixing, keeping what the visitor already typed.
- Saves each sign-up in a SQLite database, so it's still there tomorrow.
- Shows a "You're signed up!" message after a successful sign-up.
- Lists everyone who's coming.
- Closes the form when the event is full.
And it's secure: no XSS, no SQL injection, no forged forms.
It's the kind of small app that clubs, classes, and teams really use. And it uses something from nearly every chapter of this track.
Getting set up
This is a project for your own computer.
- Make a folder called
event-signup, with three folders inside it:public,src, anddata. - Create
public/index.php, and copy the starter code below into it. - In the
event-signupfolder, start the built-in server with thepublicfolder as the website's root:
php -S localhost:8000 -t public- Open
http://localhost:8000in your browser.
The -t public part means only the files in public can be reached from the browser. Your classes in src and your database in data stay private, as you learned in Reading and Writing Files. Real PHP projects are laid out the same way:
event-signup/
├── data/
│ └── signups.db (created by your code)
├── public/
│ └── index.php (the only page visitors can reach)
└── src/
├── bootstrap.php (settings, session, and helper functions)
└── SignupRepository.phpThe starter code already runs. It shows the page and the form, but submitting it only prints TODO: handle the form. Your job is to replace every TODO with the real thing.
Running PHP here
Running PHP right in the browser is coming to Syntaxia soon. You can read and edit the starter code here, but build and run the project on your own computer, with the built-in server. If you haven't set PHP up yet, Setting Up walks you through it.
Work through the requirements in order, and reload the page after each one. Small steps, tested often, are how every real program gets built.
Requirements
Each requirement says what to build, why it matters, and which lessons to review.
1. A database, behind a repository class
- Connect to
data/signups.dbwith PDO, and set the default fetch mode toPDO::FETCH_ASSOC. - Write a class
SignupRepositoryinsrc/SignupRepository.php. Its constructor takes thePDOobject, and creates thesignupstable if it doesn't exist yet (CREATE TABLE IF NOT EXISTS), withid,name,email,shirt_size,note, andcreated_atcolumns. MakeemailUNIQUE. - Give it four methods:
all(): array(every sign-up, in the order they arrived),count(): int,emailExists(string $email): bool, andadd(...). - Every query that includes a value uses a prepared statement with placeholders.
Why: Keeping all the SQL in one class means the page itself never has to think about SQL, and if you ever switch to MySQL, there's one file to check. Review: Databases with PDO, Classes and Objects, and Constructors and Visibility.
2. Validation you can trust
Write a function validateSignup(array $input, SignupRepository $signups): array that returns the cleaned values and an array of error messages keyed by field name:
- Name: trimmed; required; at most 60 characters (use
mb_strlen). - Email: trimmed and lowercased; must pass
FILTER_VALIDATE_EMAIL; must not already be signed up. (Lowercasing first meansMaria@Example.comcounts as the same person asmaria@example.com.) - Shirt size: must be one of
SHIRT_SIZES, checked within_array(..., true). Visitors can change the dropdown's values in their browser's developer tools, so never trust them. - Note: optional, trimmed, at most 200 characters.
Why: Browser checks are easy to get around, so the server checks everything. Returning errors, instead of stopping at the first one, lets visitors fix every problem in one go. Review: Validating and Escaping User Input, Working with Strings, and Parameters, Types, and Return Values.
3. A form that remembers
- When there are errors, show the form again, with each message right below its field, and with every field still holding what the visitor typed, including the chosen shirt size (the
selectedattribute). - Give each error message
role="alert", so screen readers announce it. - When there are no errors, save the sign-up with the repository.
Why: Nobody wants to retype a whole form because one field was wrong. Review: Handling Forms: GET and POST, Making Decisions (the template syntax), and Loops.
4. Security, everywhere
- Escape every value you put into the page with an
e()helper built onhtmlspecialchars, including names from the database. - Add a CSRF token: make it once per session with
random_bytes, put it in a hidden field, and check it withhash_equalson every POST. If it's missing or wrong, send status403and stop. - Use prepared statements for every query with a value (requirement 1).
Why: A sign-up sheet is public. Somebody will try typing <script> as their name. Review: Security Essentials and Sessions and Cookies.
5. Post/Redirect/Get, with a message
- After a successful sign-up, store a message like
You're signed up, Maria Santos! See you there.in$_SESSION["flash"], then redirect to/withheader("Location: /")andexit. - On the next page load, show the message once, then remove it from the session. (A message that shows once, then disappears, is often called a flash message.)
Why: Without the redirect, refreshing the page after signing up would ask to resubmit the form, and try to sign the same person up twice. Review: Handling Forms and Sessions and Cookies.
6. The attendee list and the capacity
- Show
X of 5 spots left., using aCAPACITYconstant. - List everyone who's signed up, in order, with their shirt size, as an
<ol>. When nobody has signed up yet, showNobody yet. Be the first!instead of an empty list. - When the event is full, hide the form and show
Sorry, this event is full. - The server must also refuse a sign-up when the event is full, even if the form was submitted anyway, for example by someone who opened the page while there was still one spot left. Show a message explaining what happened.
Why: The page can only show what was true when it was loaded; the server is the one that actually decides. Review: Indexed Arrays and Associative Arrays.
7. Clean, organized code
declare(strict_types=1);at the top of every file, and types on every parameter, return value, and property.- The settings (
EVENT_NAME,CAPACITY,SHIRT_SIZES, the maximum lengths) are constants insrc/bootstrap.php, which also starts the session and holds the helper functions.public/index.phploads it withrequire. - All the PHP logic happens at the top of
index.php; the HTML below it only displays results. - PSR-12 style, clear names, no magic numbers, and no empty
catchblocks.
Why: You'll come back to this code. So might a teammate, a teacher, or a future employer. Review: Splitting Code with include and require and Best Practices and Common Mistakes.
Sample run
Your wording and layout don't have to match exactly, but your app should handle every situation here. This shows what's inside <main> on the page, as HTML, with the form fields left out to save space.
First visit, before anyone has signed up:
<h1>Coding Club Hackathon</h1>
<p>5 of 5 spots left.</p>
<h2>Who's coming (0)</h2>
<p>Nobody yet. Be the first!</p>Maria signs up with a valid name, email, and size M. After the redirect:
<h1>Coding Club Hackathon</h1>
<p role="status">You're signed up, Maria Santos! See you there.</p>
<p>4 of 5 spots left.</p>
<h2>Who's coming (1)</h2>
<ol>
<li>Maria Santos (M)</li>
</ol>(' is the escaped apostrophe; the browser shows it as You're.) Reloading the page shows the same list, but the message is gone.
Someone submits a blank name, the email ben@, and a shirt size of XXL (changed in the browser's developer tools):
<p role="alert">Please enter your name.</p>
<p role="alert">Please enter a valid email address, like maria@example.com.</p>
<p role="alert">Please choose a shirt size from the list.</p>Each message appears below its own field, and nothing is saved.
Someone signs up again as MARIA@Example.com, with spaces and capitals:
<p role="alert">That email is already signed up.</p>Someone signs up with the name <script>alert(1)</script>. It's saved exactly as typed, but it's escaped on the way out, so it appears as harmless text and never runs:
<li><script>alert(1)</script> (L)</li>A form is sent without the CSRF token (as a forged form from another site would be): status 403, and just this:
This form has expired. Please go back, reload the page, and try again.The fifth person signs up, and the event is full:
<p role="status">You're signed up, Paolo Lim! See you there.</p>
<p>0 of 5 spots left.</p>
<p>Sorry, this event is full.</p>
<h2>Who's coming (5)</h2>A form submitted from a page opened before the event filled up:
<p role="alert">Sorry, the event filled up while you were signing up.</p>
<p>0 of 5 spots left.</p>
<p>Sorry, this event is full.</p>Testing your app
There's no automatic checker for this project, so you're the tester. Try each of these, and make sure nothing breaks:
- Load the page before anyone has signed up.
- Submit the form completely empty.
- Type a name of 61 characters, and a note of 201.
- Try the emails
maria@,maria@example, and a real-looking one that's already signed up, in capitals. - In your browser's developer tools (right-click the dropdown, then Inspect), change a size option's value to
XXL, choose it, and submit. - Sign up with the name
<b>Bold</b>and check that the tags appear as text. - Delete the hidden
csrf_tokenfield in the developer tools, then submit. You should get the 403 message. - Sign up successfully, then press refresh. Nothing should be resubmitted.
- Fill all 5 spots, and check that the form disappears.
- Open the page in two tabs while one spot is left. Sign up in the first tab, then in the second. The second must be refused.
- Stop the server, start it again, and check the list is still there.
- Try to open
http://localhost:8000/data/signups.dbandhttp://localhost:8000/src/bootstrap.php. Neither file should download or show its code. (The built-in server shows your sign-up page instead, since those files aren't inpublic.)
To start over with an empty list at any time, stop the server and delete data/signups.db.
Stretch goals (optional, for the ambitious)
- Add a simple admin page,
public/admin.php, protected by a password stored as apassword_hash, that lists everyone's email and notes, with a button to remove a sign-up. - Add a waiting list: when the event is full, let people join the list instead, and move the first person up when someone is removed.
- Show how many of each shirt size are needed, with
GROUP BYin SQL, orarray_count_valuesin PHP. - Add a
public/api.phpthat sends the attendee list as JSON, using Working with JSON. - Use Composer's autoloader with a
composer.jsonand PSR-4, as in Namespaces and Composer, instead ofrequire_oncefor your classes. - Support several events, each with its own capacity, with an
eventstable and a foreign key.
Self-check
This is a building project, not a test, so there's no score. Answer each question honestly, yes or no, about your own code. Every "no" is just a pointer to your next improvement.
- Did I try every case in "Testing your app," and did the app survive all of them?
- Is every value that goes into the page escaped, including names that come from the database?
- Does every query that includes a value use a placeholder?
- Does every error message tell the visitor what went wrong and what to do instead?
- If I change the capacity or add a shirt size, is it a one-line change?
- Is all the SQL inside
SignupRepository, and all the logic above the HTML inindex.php? - If I came back to this in six months, could I add a new field to the form without being afraid of breaking the others?
- Would I be happy to share the link to this sign-up sheet with my own club or class?
If you answered yes to all eight, you've built a real, complete, secure PHP web application. If a few came back "no," that's completely normal. Go back and improve them. The second pass is where good programmers become great ones.
Stuck? A reference solution
Try to finish on your own first. Struggling with a problem, and then solving it, is exactly how the skill sticks. But if you're truly stuck on one part, or you've finished and want to compare, here's one complete solution. It's not the only right answer; if yours works and reads clearly, it's just as good.
Show the reference solution
src/SignupRepository.php:
<?php
declare(strict_types=1);
final class SignupRepository
{
public function __construct(private PDO $db)
{
$this->db->exec("CREATE TABLE IF NOT EXISTS signups (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
shirt_size TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)");
}
public function all(): array
{
return $this->db->query("SELECT name, shirt_size FROM signups ORDER BY id")->fetchAll();
}
public function count(): int
{
return (int) $this->db->query("SELECT COUNT(*) FROM signups")->fetchColumn();
}
public function emailExists(string $email): bool
{
$stmt = $this->db->prepare("SELECT 1 FROM signups WHERE email = ?");
$stmt->execute([$email]);
return $stmt->fetchColumn() !== false;
}
public function add(string $name, string $email, string $shirtSize, string $note): void
{
$stmt = $this->db->prepare(
"INSERT INTO signups (name, email, shirt_size, note) VALUES (:name, :email, :size, :note)"
);
$stmt->execute(["name" => $name, "email" => $email, "size" => $shirtSize, "note" => $note]);
}
}src/bootstrap.php:
<?php
declare(strict_types=1);
require_once __DIR__ . "/SignupRepository.php";
const EVENT_NAME = "Coding Club Hackathon";
const CAPACITY = 5;
const SHIRT_SIZES = ["S", "M", "L", "XL"];
const MAX_NAME_LENGTH = 60;
const MAX_NOTE_LENGTH = 200;
session_start();
function e(string $text): string
{
return htmlspecialchars($text, ENT_QUOTES, "UTF-8");
}
function csrfToken(): string
{
if (!isset($_SESSION["csrf_token"])) {
$_SESSION["csrf_token"] = bin2hex(random_bytes(32));
}
return $_SESSION["csrf_token"];
}
function isValidCsrfToken(string $sent): bool
{
return isset($_SESSION["csrf_token"]) && hash_equals($_SESSION["csrf_token"], $sent);
}
function takeFlash(): ?string
{
$message = $_SESSION["flash"] ?? null;
unset($_SESSION["flash"]);
return $message;
}
/**
* Checks the submitted form. Returns the cleaned values and a list of errors, keyed by field.
*/
function validateSignup(array $input, SignupRepository $signups): array
{
$values = [
"name" => trim((string) ($input["name"] ?? "")),
"email" => strtolower(trim((string) ($input["email"] ?? ""))),
"shirt_size" => (string) ($input["shirt_size"] ?? ""),
"note" => trim((string) ($input["note"] ?? "")),
];
$errors = [];
if ($values["name"] === "") {
$errors["name"] = "Please enter your name.";
} elseif (mb_strlen($values["name"]) > MAX_NAME_LENGTH) {
$errors["name"] = "Your name must be " . MAX_NAME_LENGTH . " characters or fewer.";
}
if (filter_var($values["email"], FILTER_VALIDATE_EMAIL) === false) {
$errors["email"] = "Please enter a valid email address, like maria@example.com.";
} elseif ($signups->emailExists($values["email"])) {
$errors["email"] = "That email is already signed up.";
}
if (!in_array($values["shirt_size"], SHIRT_SIZES, true)) {
$errors["shirt_size"] = "Please choose a shirt size from the list.";
}
if (mb_strlen($values["note"]) > MAX_NOTE_LENGTH) {
$errors["note"] = "Please keep the note to " . MAX_NOTE_LENGTH . " characters or fewer.";
}
return [$values, $errors];
}public/index.php:
<?php
declare(strict_types=1);
require __DIR__ . "/../src/bootstrap.php";
$db = new PDO("sqlite:" . __DIR__ . "/../data/signups.db", options: [
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$signups = new SignupRepository($db);
$values = ["name" => "", "email" => "", "shirt_size" => "", "note" => ""];
$errors = [];
if ($_SERVER["REQUEST_METHOD"] === "POST") {
if (!isValidCsrfToken((string) ($_POST["csrf_token"] ?? ""))) {
http_response_code(403);
exit("This form has expired. Please go back, reload the page, and try again.");
}
[$values, $errors] = validateSignup($_POST, $signups);
if ($signups->count() >= CAPACITY) {
$errors["form"] = "Sorry, the event filled up while you were signing up.";
}
if ($errors === []) {
$signups->add($values["name"], $values["email"], $values["shirt_size"], $values["note"]);
$_SESSION["flash"] = "You're signed up, " . $values["name"] . "! See you there.";
header("Location: /");
exit;
}
}
$attendees = $signups->all();
$spotsLeft = CAPACITY - count($attendees);
$flash = takeFlash();
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign up: <?= e(EVENT_NAME) ?></title>
</head>
<body>
<main>
<h1><?= e(EVENT_NAME) ?></h1>
<?php if ($flash !== null): ?>
<p role="status"><?= e($flash) ?></p>
<?php endif; ?>
<?php if (isset($errors["form"])): ?>
<p role="alert"><?= e($errors["form"]) ?></p>
<?php endif; ?>
<p><?= $spotsLeft ?> of <?= CAPACITY ?> spots left.</p>
<?php if ($spotsLeft > 0): ?>
<form method="post" novalidate>
<input type="hidden" name="csrf_token" value="<?= e(csrfToken()) ?>">
<label for="name">Name</label>
<input type="text" id="name" name="name" value="<?= e($values["name"]) ?>" required>
<?php if (isset($errors["name"])): ?>
<p role="alert"><?= e($errors["name"]) ?></p>
<?php endif; ?>
<label for="email">Email</label>
<input type="email" id="email" name="email" value="<?= e($values["email"]) ?>" required>
<?php if (isset($errors["email"])): ?>
<p role="alert"><?= e($errors["email"]) ?></p>
<?php endif; ?>
<label for="shirt_size">Shirt size</label>
<select id="shirt_size" name="shirt_size" required>
<option value="">Choose a size</option>
<?php foreach (SHIRT_SIZES as $size): ?>
<option value="<?= e($size) ?>"<?= $values["shirt_size"] === $size ? " selected" : "" ?>><?= e($size) ?></option>
<?php endforeach; ?>
</select>
<?php if (isset($errors["shirt_size"])): ?>
<p role="alert"><?= e($errors["shirt_size"]) ?></p>
<?php endif; ?>
<label for="note">Anything we should know? (optional)</label>
<textarea id="note" name="note"><?= e($values["note"]) ?></textarea>
<?php if (isset($errors["note"])): ?>
<p role="alert"><?= e($errors["note"]) ?></p>
<?php endif; ?>
<button>Sign me up</button>
</form>
<?php else: ?>
<p>Sorry, this event is full.</p>
<?php endif; ?>
<h2>Who's coming (<?= count($attendees) ?>)</h2>
<?php if ($attendees === []): ?>
<p>Nobody yet. Be the first!</p>
<?php else: ?>
<ol>
<?php foreach ($attendees as $attendee): ?>
<li><?= e($attendee["name"]) ?> (<?= e($attendee["shirt_size"]) ?>)</li>
<?php endforeach; ?>
</ol>
<?php endif; ?>
</main>
</body>
</html>Check your understanding
Where you go from here
You've finished the PHP track. Take a moment with that. You started with a line of echo, and you've just built a secure web application with a database, validation, sessions, and protection against the most common attacks on the web.
PHP opens a lot of doors from here. Laravel is the most popular PHP framework, and it gives you routing, templates, a database toolkit, logins, and much more, all built on the ideas you know now; the Laravel track picks up where this one ends. WordPress, which runs a huge share of the web, is PHP too, and the WordPress track shows how to build themes and plugins for it. And when your apps need a bigger database, the MySQL track builds on the SQL you already have.
Whichever way you go, the ideas you've learned here, requests and responses, validating and escaping, prepared statements, and the patience to read an error message calmly, travel with you into every framework and language you learn next. Well done.
