Lesson 49 • Capstone
Final Project 🏆
Build a complete PHP app end-to-end — TaskFlow, a small task manager — across six numbered milestones: a database and PDO connection, an MVC router, secure auth, validated CRUD, a JSON API, and a deploy checklist. By the end you'll have wired together everything this course taught into one portfolio-ready application.
What You'll Build
- A schema for users and tasks, opened through one reusable PDO connection
- A clean MVC layout with a front-controller router mapping URLs to actions
- Secure register & login using password_hash, password_verify, and sessions
- Full CRUD on tasks with prepared statements, validation, and XSS-safe output
- A JSON API endpoint that returns the same data machine-readable
- A deploy & security checklist you run before going live
php -S localhost:8000 -t public. The milestones use SQLite so they run anywhere; swap the DSN for MySQL/PostgreSQL in production.1️⃣ Schema & PDO Connection
Every app starts with its data. You define the tables once, then open one database connection that the whole app shares. PDO (PHP Data Objects) is PHP's built-in, database-agnostic way to talk to SQL: the same code works against MySQL, PostgreSQL, or SQLite by changing only the connection string. Setting ERRMODE_EXCEPTION means a broken query throws an error you can see, instead of failing silently.
<?php
// 🧱 MILESTONE 1 — Schema + a single, reusable PDO connection.
// Run this on onecompiler.com/php to SEE the plan; run the SQL
// against MySQL/SQLite in your real project via $pdo->exec().
declare(strict_types=1);
// 1) The SQL that creates our two tables. A heredoc ( <<<SQL ... SQL; )
// keeps multi-line text readable. owner_id links a task to a user.
$schema = <<<SQL
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER NOT NULL REFERENCES users(id),
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'todo',
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
SQL;
// 2) ONE place that builds the database connection. Returning a
// configured PDO object means every file uses the same safe setup.
function db(): PDO
{
// In a real app the DSN/credentials come from environment variables,
// never hard-coded. SQLite is shown so this runs anywhere.
$pdo = new PDO("sqlite::memory:");
// ERRMODE_EXCEPTION: turn silent SQL failures into catchable errors.
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// FETCH_ASSOC: rows come back as ["title" => ...], not numbered.
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
return $pdo;
}
$pdo = db();
$pdo->exec($schema); // create the tables once
$tables = $pdo->query("SELECT name FROM sqlite_master WHERE type='table'")
->fetchAll(PDO::FETCH_COLUMN);
echo "Connected. Tables created: " . implode(", ", $tables) . "\n";
?>Connected. Tables created: users, tasksThe db() function is the single source of truth for your connection — every later milestone calls it. In production the credentials come from environment variables, never hard-coded into a file you commit.
2️⃣ MVC Structure & Router
MVC (Model–View–Controller) splits your app into three jobs: Models talk to the database, Controllers decide what happens for a request, and Views render the HTML. A front-controller router sends every request through one file (public/index.php) that matches the URL to a controller action. Only the public/ folder is web-accessible — your app/ code stays out of reach of the browser.
<?php
// 🧱 MILESTONE 2 — MVC layout + a tiny front-controller router.
// Folder layout (every request enters through public/index.php):
// public/index.php <- the ONLY file the web server runs
// app/Controllers/... <- decide what happens for a request
// app/Models/... <- talk to the database
// app/Views/... <- the HTML templates
declare(strict_types=1);
final class Router
{
/** @var array<string, callable> "VERB path" => handler */
private array $routes = [];
// Register "GET /tasks" -> a function. add() returns nothing.
public function add(string $method, string $path, callable $handler): void
{
$this->routes["$method $path"] = $handler;
}
// Match the incoming request to a route, or return a 404 message.
public function dispatch(string $method, string $path): string
{
$key = "$method $path";
if (!isset($this->routes[$key])) {
return "404 Not Found: $key";
}
return ($this->routes[$key])(); // run the matched handler
}
}
$router = new Router();
$router->add("GET", "/", fn() => "Home: welcome to TaskFlow");
$router->add("GET", "/tasks", fn() => "TaskController@index -> list of tasks");
$router->add("POST", "/tasks", fn() => "TaskController@store -> task created");
// Simulate three incoming requests (the web server normally feeds these in):
echo $router->dispatch("GET", "/") . "\n";
echo $router->dispatch("GET", "/tasks") . "\n";
echo $router->dispatch("POST", "/tasks") . "\n";
echo $router->dispatch("GET", "/nope") . "\n";
?>Home: welcome to TaskFlow
TaskController@index -> list of tasks
TaskController@store -> task created
404 Not Found: GET /nope3️⃣ Authentication (Register & Login)
Now you know who the user is. The golden rule: never store a raw password. password_hash() turns it into a one-way, salted hash; password_verify() checks a login attempt against that hash without ever un-hashing it. Once a login succeeds, you remember the user in a session — a small server-side store keyed to a cookie, so the user stays logged in across requests.
<?php
// 🧱 MILESTONE 3 — Register & log in with password_hash + sessions.
// GOLDEN RULE: never store a raw password. Hash it one way; you can
// verify a guess against the hash but you can never read it back.
declare(strict_types=1);
// REGISTER — turn a plaintext password into a safe, salted hash.
function registerUser(PDO $pdo, string $email, string $password): int
{
// password_hash() picks a strong algorithm and a random salt for you.
$hash = password_hash($password, PASSWORD_DEFAULT);
// Prepared statement: the email is data, never glued into the SQL.
$stmt = $pdo->prepare("INSERT INTO users (name, email, password_hash) VALUES (?, ?, ?)");
$stmt->execute(["New User", $email, $hash]);
return (int) $pdo->lastInsertId(); // the new user's id
}
// LOGIN — look up the user, then verify the typed password vs the hash.
function login(PDO $pdo, string $email, string $password): bool
{
$stmt = $pdo->prepare("SELECT id, password_hash FROM users WHERE email = ?");
$stmt->execute([$email]);
$user = $stmt->fetch();
// password_verify() re-hashes the guess and compares safely.
if ($user && password_verify($password, $user["password_hash"])) {
// Real app: session_start(); then $_SESSION["user_id"] = $user["id"];
$_SESSION["user_id"] = $user["id"]; // "remember who is logged in"
return true;
}
return false; // wrong email OR wrong password
}
$pdo = db(); // db() from Milestone 1
$pdo->exec("CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT, email TEXT UNIQUE, password_hash TEXT)");
$id = registerUser($pdo, "ada@example.com", "correct horse battery");
echo "Registered user #$id\n";
echo login($pdo, "ada@example.com", "correct horse battery") ? "Login OK\n" : "Denied\n";
echo login($pdo, "ada@example.com", "wrong-password") ? "Login OK\n" : "Denied\n";
?>Registered user #1
Login OK
DeniedNotice the login is deliberately vague about why it failed (wrong email vs wrong password) — that's intentional, so an attacker can't probe which emails are registered.
4️⃣ CRUD with Prepared Statements & Escaping
CRUD means Create, Read, Update, Delete — the four things every app does to its data. Two rules keep it safe. On the way in: validate input (trim it, reject empties, cap the length) and pass every value as a bound parameter (the ? placeholders) so SQL injection is impossible. On the way out: wrap anything you echo into HTML in htmlspecialchars() so a hostile <script> is shown as text, not executed.
<?php
// 🧱 MILESTONE 4 — CRUD with prepared statements, validation & XSS escaping.
// Two non-negotiables: (1) every value goes in as a bound parameter (?),
// (2) every value coming OUT to HTML is escaped with htmlspecialchars().
declare(strict_types=1);
// VALIDATE first — trim, check it's not empty, cap the length.
function validateTitle(string $raw): string
{
$title = trim($raw);
if ($title === "") throw new InvalidArgumentException("Title is required.");
if (mb_strlen($title) > 300) throw new InvalidArgumentException("Title too long.");
return $title;
}
// CREATE — bound parameters make SQL injection impossible here.
function createTask(PDO $pdo, int $ownerId, string $rawTitle): int
{
$title = validateTitle($rawTitle);
$stmt = $pdo->prepare("INSERT INTO tasks (owner_id, title, status) VALUES (?, ?, 'todo')");
$stmt->execute([$ownerId, $title]);
return (int) $pdo->lastInsertId();
}
// READ — fetch this user's tasks only (never trust the caller for ownerId).
function listTasks(PDO $pdo, int $ownerId): array
{
$stmt = $pdo->prepare("SELECT id, title, status FROM tasks WHERE owner_id = ? ORDER BY id");
$stmt->execute([$ownerId]);
return $stmt->fetchAll();
}
$pdo = db();
$pdo->exec("CREATE TABLE tasks (id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER, title TEXT, status TEXT)");
createTask($pdo, 1, " Write the README "); // trimmed by validate
createTask($pdo, 1, "Ship <script>alert(1)</script>"); // a hostile title
// ESCAPE on output — htmlspecialchars() turns < into < so the
// browser shows the text instead of RUNNING it (this stops XSS).
foreach (listTasks($pdo, 1) as $task) {
$safe = htmlspecialchars($task["title"], ENT_QUOTES, "UTF-8");
echo "#{$task['id']} [{$task['status']}] $safe\n";
}
?>#1 [todo] Write the README
#2 [todo] Ship <script>alert(1)</script>Look at task #2 in the output: the hostile <script> was stored verbatim (the database is just data) but rendered harmless because it was escaped on output. Escape at the boundary where data becomes HTML — that's the whole trick to stopping XSS.
5️⃣ A JSON API Endpoint
A web page is for humans; a JSON API is the same data for machines — your future JavaScript front-end, a mobile app, or another service. The pattern is simple: send a Content-Type: application/json header, set the right HTTP status code, and echo json_encode(...). Reusing the same query from Milestone 4 means your HTML page and your API never drift apart.
<?php
// 🧱 MILESTONE 5 — A JSON API endpoint. Same data, machine-readable.
// A real endpoint sends a Content-Type header and the right status code,
// then echoes json_encode(...). We print the pieces so you can verify them.
declare(strict_types=1);
// Build one consistent response shape for every API reply.
function jsonResponse(array $data, int $status = 200): array
{
// Real app:
// header("Content-Type: application/json");
// http_response_code($status);
// echo json_encode($payload);
return [
"status" => $status,
"body" => json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
];
}
// GET /api/tasks — return the logged-in user's tasks as JSON.
function apiListTasks(PDO $pdo, int $ownerId): array
{
$tasks = listTasks($pdo, $ownerId); // reuse Milestone 4's query
return jsonResponse(["data" => $tasks, "count" => count($tasks)]);
}
$pdo = db();
$pdo->exec("CREATE TABLE tasks (id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER, title TEXT, status TEXT)");
createTask($pdo, 1, "Write the README"); // createTask() from Milestone 4
$response = apiListTasks($pdo, 1);
echo "HTTP {$response['status']}\n";
echo $response["body"] . "\n";
?>HTTP 200
{
"data": [
{
"id": 1,
"title": "Write the README",
"status": "todo"
}
],
"count": 1
}6️⃣ Deploy & Security Checklist
Working on your laptop is not the same as being live. Before you ship, run a checklist — secrets out of the repo, errors hidden from users but logged, HTTPS forced, every query prepared, every output escaped. The script below prints that gate so you can tick it off for real, not from memory.
<?php
// 🧱 MILESTONE 6 — Deploy & security checklist as runnable code.
// This prints your go-live gate. Each line is a real habit, not a slogan.
declare(strict_types=1);
$checklist = [
"Secrets" => "DB password & API keys in env vars, NOT in the repo",
"Errors" => "display_errors = Off in production; log to a file instead",
"HTTPS" => "Force TLS; set session.cookie_secure & cookie_httponly = 1",
"SQL" => "Every query uses prepared statements (zero string-glued SQL)",
"Output" => "Every echo into HTML wrapped in htmlspecialchars()",
"Passwords" => "password_hash() to store, password_verify() to check",
"Validation" => "Validate & length-cap all input on the server side",
"Updates" => "composer update for security patches; pin PHP version",
];
echo "=== TaskFlow go-live checklist ===\n";
$n = 0;
foreach ($checklist as $area => $rule) {
$n++;
printf("[%d] %-10s -> %s\n", $n, $area, $rule);
}
echo "\nShip it. " . count($checklist) . " gates passed.\n";
?>=== TaskFlow go-live checklist ===
[1] Secrets -> DB password & API keys in env vars, NOT in the repo
[2] Errors -> display_errors = Off in production; log to a file instead
[3] HTTPS -> Force TLS; set session.cookie_secure & cookie_httponly = 1
[4] SQL -> Every query uses prepared statements (zero string-glued SQL)
[5] Output -> Every echo into HTML wrapped in htmlspecialchars()
[6] Passwords -> password_hash() to store, password_verify() to check
[7] Validation -> Validate & length-cap all input on the server side
[8] Updates -> composer update for security patches; pin PHP version
Ship it. 8 gates passed.🎯 Your Turn — Build the Update
Milestone 4 created and read tasks; now add the U in CRUD. The script below is almost done — fill in each ___ using the 👉 hints, then run it and check it against the Output panel. The point: an UPDATE must use bound parameters and a WHERE owner_id = ? so one user can never edit another's task.
<?php
// 🎯 YOUR TURN — finish the UPDATE in our CRUD. Fill each ___ , then run.
// You are toggling a task's status to "done" — safely, with bound params.
declare(strict_types=1);
function completeTask(PDO $pdo, int $ownerId, int $taskId): void
{
// 1) Write the UPDATE. owner_id in the WHERE stops users editing
// each other's tasks. Use ? placeholders, never string-glued values.
$sql = "UPDATE tasks SET status = 'done' WHERE id = ___ AND owner_id = ___";
// 👆 👆
// replace each ___ with a ?
$stmt = $pdo->prepare($sql);
// 2) Bind the values in the SAME ORDER as the ? marks: id first, owner second.
$stmt->execute([___, ___]); // 👉 e.g. [$taskId, $ownerId]
}
$pdo = db();
$pdo->exec("CREATE TABLE tasks (id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER, title TEXT, status TEXT)");
$pdo->exec("INSERT INTO tasks (owner_id, title, status) VALUES (1, 'Demo', 'todo')");
completeTask($pdo, 1, 1);
$row = $pdo->query("SELECT title, status FROM tasks WHERE id = 1")->fetch();
echo "{$row['title']}: {$row['status']}\n";
// ✅ Expected output:
// Demo: done
?>Demo: done___ in the SQL with ?, then pass [$taskId, $ownerId] to execute() in that order. Run it on onecompiler.com/php; expect Demo: done.🎯 Your Turn — Guard the Private Pages
Auth is only useful if it actually blocks guests. A guard runs before a controller and checks the session. Finish the two ___ below: test the session with isset(), and return the redirect text when no user is present.
<?php
// 🎯 YOUR TURN — write the "auth guard" that protects private pages.
// A guard checks the session BEFORE a controller runs. Fill the ___ .
declare(strict_types=1);
function requireLogin(array $session): string
{
// 1) Is a logged-in user id present in the session?
if (___($session["user_id"])) { // 👉 use isset(...) to test it
return "Allowed: user #" . $session["user_id"];
}
// 2) No user id -> block the request.
return "___"; // 👉 return the text: Redirect to /login
}
// A logged-in request carries the user id; a guest's session is empty.
echo requireLogin(["user_id" => 7]) . "\n";
echo requireLogin([]) . "\n";
// ✅ Expected output:
// Allowed: user #7
// Redirect to /login
?>Allowed: user #7
Redirect to /loginisset(...) for the check, and return the text Redirect to /login for a guest. The two test calls should print an allow line and a redirect line.Common Pitfalls (and the fix)
- "SQLSTATE... syntax error" when a value has a quote in it — you glued the value into the SQL string. Switch to a prepared statement: put a
?in the SQL and pass the value toexecute([...]). This fixes the error and closes the injection hole. - A user's
<script>runs on your page — you echoed stored data straight into HTML. Wrap it:htmlspecialchars($value, ENT_QUOTES, "UTF-8"). Escape on output, every time. - "Warning: session_start(): headers already sent" — you printed something (even a blank line before
<?php) before callingsession_start(). Start the session at the very top, before any output. - Login always fails even with the right password — you compared with
==instead of usingpassword_verify(), or you truncated the hash column. Store the full hash in aVARCHAR(255)and verify withpassword_verify($input, $hash). - "Call to a member function prepare() on null" — your
db()returned nothing or the connection threw. SetPDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTIONso the real connection error surfaces instead of a null.
Pro Tips
- 💡 One connection, one config. Build PDO in a single
db()function so error modes and fetch defaults are set once, everywhere. - 💡 Validate in, escape out. These are two different jobs at two different boundaries — do both, never just one.
- 💡 Reuse the same model query for your HTML page and your JSON API so they can never disagree.
- 💡 Keep secrets in
.envand add it to.gitignoreon day one — it's painful to scrub a leaked password from git history later.
📋 Quick Reference — Capstone Building Blocks
| Task | Tool | Example |
|---|---|---|
| Connect to DB | new PDO(...) | new PDO("sqlite:app.db") |
| Safe query | prepare / execute | $pdo->prepare("... WHERE id = ?") |
| Store password | password_hash | password_hash($pw, PASSWORD_DEFAULT) |
| Check password | password_verify | password_verify($pw, $hash) |
| Remember user | $_SESSION | $_SESSION["user_id"] = $id; |
| Escape for HTML | htmlspecialchars | htmlspecialchars($v, ENT_QUOTES) |
| Return JSON | json_encode | echo json_encode($data); |
Frequently Asked Questions
Q: Why use PDO and prepared statements instead of just building the SQL string?
Because string-built SQL is the classic way to get hacked. If you write "... WHERE email = '$email'", a visitor can type an email like ' OR '1'='1 and rewrite your query. A prepared statement sends the SQL and the values to the database separately, so user input is always treated as data and never as code. PDO also gives you one consistent API across MySQL, PostgreSQL, and SQLite, and ERRMODE_EXCEPTION turns silent SQL failures into errors you can actually see and catch.
Q: Is password_hash() really enough — don't I need my own salt?
password_hash() is enough, and rolling your own salt is a mistake. It automatically generates a cryptographically strong random salt and stores it inside the resulting hash string, using a deliberately slow, modern algorithm (bcrypt by default via PASSWORD_DEFAULT). You store the whole string in one column and check a login with password_verify(). Never use md5() or sha1() for passwords — they are fast, which is exactly what an attacker wants, and they have no built-in salt.
Q: What is XSS and how does htmlspecialchars() stop it?
XSS (cross-site scripting) is when an attacker stores something like <script>...</script> in a field, and your page later prints it straight into the HTML, so the browser runs their script for every visitor. htmlspecialchars() converts the dangerous characters — < > " ' & — into harmless HTML entities (< > and so on), so the browser displays the text instead of executing it. The rule of thumb: escape on output, every time you echo user-controlled data into HTML, using htmlspecialchars($value, ENT_QUOTES, 'UTF-8').
Q: Why route every request through a single public/index.php?
A single front controller (public/index.php) is the entry point for every request, which lets you do shared setup once — load config, start the session, set security headers, and dispatch to the right controller. It also means only your public/ folder is web-accessible, so your app/ code, config, and .env file sit outside the document root where browsers cannot reach them. Every modern PHP framework (Laravel, Symfony, Slim) works this way for exactly these reasons.
Q: Where do I deploy a PHP app, and what's the minimum to do it safely?
Any host with PHP works — a cheap shared host, a VPS you run nginx/Apache + PHP-FPM on, or a platform like Railway or Render. The non-negotiable minimum: serve only over HTTPS, keep secrets in environment variables (never committed to git), set display_errors = Off and log errors to a file, point the web root at public/ only, and run composer install --no-dev on the server. The Milestone 6 checklist in this lesson is your go-live gate — don't ship until every line passes.
Stretch Challenge: Search Tasks
No code is filled in this time — just a brief and an outline. Combine three milestones (a model query, a JSON endpoint, and output escaping) into one feature. Build it yourself, run it on onecompiler.com/php or your own machine, then check it against the expected output in the comments. This is exactly how you'll extend a real app.
<?php
// 🎯 STRETCH CHALLENGE: add a "search tasks" feature, end-to-end.
// No code is filled in — design it from the milestones you just built.
//
// 1. A function searchTasks(PDO $pdo, int $ownerId, string $term): array
// - Validate $term (trim it; if empty, return [] — don't query).
// - Use a prepared statement with a LIKE filter:
// WHERE owner_id = ? AND title LIKE ?
// and bind the term as "%" . $term . "%" (still a BOUND parameter!).
// 2. A JSON endpoint GET /api/tasks/search?q=... that calls it and returns
// jsonResponse(["data" => $results]) — reuse Milestone 5's helper.
// 3. When you echo any matched title into HTML, wrap it in htmlspecialchars().
//
// ✅ Expected (titles "Buy milk", "Buy bread"; search "buy"):
// HTTP 200 with both rows in "data".
//
// Tips: the % wildcards live in the VALUE you bind, not in the SQL string.
// Never build LIKE '%$term%' by hand — that re-opens SQL injection.
// your code here
?>searchTasks() with a bound LIKE ? filter (the % wildcards go in the value you bind, not the SQL), expose it as a JSON endpoint, and escape any title you render. Searching "buy" should return both "Buy milk" and "Buy bread".🎉 Course Complete!
- ✅ You built a complete app, TaskFlow, across six milestones — schema, router, auth, CRUD, JSON API, and a deploy gate
- ✅ You connect once with PDO and run every query through prepared statements
- ✅ You store passwords with
password_hash()and keep users logged in with sessions - ✅ You validate input on the way in and escape output on the way out to stop injection and XSS
- ✅ You expose the same data as a JSON API and ship behind a security checklist
- 🚀 Where next: rebuild TaskFlow on a framework like Laravel or Symfony, add Composer packages and PHPUnit tests, learn Docker for deployment, then ship it live and put it in your portfolio
Sign up for free to track which lessons you've completed and get learning reminders.