Final Project

Reviewed & published by Brayan K

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.

Part of the free PHP course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Build

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
// sqlite_% names are SQLite's own bookkeeping tables (AUTOINCREMENT adds
// sqlite_sequence), so filter them out — you did not create them.
$tables = $pdo->query("SELECT name FROM sqlite_master
                       WHERE type='table' AND name NOT LIKE 'sqlite_%'")
              ->fetchAll(PDO::FETCH_COLUMN);

echo "Connected. Tables created: " . implode(", ", $tables) . "\n";
?>

The 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";
?>

3️⃣ 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, "[email protected]", "correct horse battery");
echo "Registered user #$id\n";
echo login($pdo, "[email protected]", "correct horse battery") ? "Login OK\n" : "Denied\n";
echo login($pdo, "[email protected]", "wrong-password")        ? "Login OK\n" : "Denied\n";
?>

Notice 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 &lt; 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";
}
?>

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";
?>

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";
?>

🎯 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
?>

🎯 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
?>

Common Pitfalls (and the fix)

Pro Tips

📋 Quick Reference — Capstone Building Blocks

TaskToolExample
Connect to DBnew PDO(...)new PDO("sqlite:app.db")
Safe queryprepare / execute$pdo->prepare("... WHERE id = ?")
Store passwordpassword_hashpassword_hash($pw, PASSWORD_DEFAULT)
Check passwordpassword_verifypassword_verify($pw, $hash)
Remember user$_SESSION$_SESSION["user_id"] = $id;
Escape for HTMLhtmlspecialcharshtmlspecialchars($v, ENT_QUOTES)
Return JSONjson_encodeecho json_encode($data);

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
?>

🎉 Course Complete!

Practice quiz

In Milestone 1, what does setting PDO::ATTR_ERRMODE to PDO::ERRMODE_EXCEPTION do?

  • Encrypts the database connection
  • Makes queries run faster
  • Turns silent SQL failures into catchable errors
  • Returns rows as numbered arrays

Answer: Turns silent SQL failures into catchable errors. ERRMODE_EXCEPTION makes a broken query throw an error you can see and catch, instead of failing silently.

What is the purpose of the front-controller router (public/index.php) in the MVC milestone?

  • It is the single entry point every request flows through, mapping a URL to a controller action
  • It stores the database password
  • It renders all the HTML templates
  • It runs the password hashing

Answer: It is the single entry point every request flows through, mapping a URL to a controller action. A single front controller boots the app once and dispatches each request to the right controller; only public/ is web-accessible.

In the auth milestone, how is a password stored and later checked?

  • Stored with md5(), checked with ==
  • Stored in plain text, checked with strcmp()
  • Stored with base64_encode(), checked with base64_decode()
  • Stored with password_hash(), checked with password_verify()

Answer: Stored with password_hash(), checked with password_verify(). password_hash() makes a one-way salted hash; password_verify() checks a login attempt against it without un-hashing.

Why does password_hash() not need you to generate your own salt?

  • It doesn't use a salt at all
  • It automatically generates a strong random salt and stores it inside the resulting hash string
  • You must always supply a salt as a second argument
  • It reuses the same salt for every password

Answer: It automatically generates a strong random salt and stores it inside the resulting hash string. password_hash() generates a cryptographically strong random salt and embeds it in the hash, using bcrypt by default.

In the CRUD milestone, how is SQL injection prevented?

  • By passing every value as a bound parameter (the ? placeholders) in a prepared statement
  • By escaping quotes with addslashes()
  • By validating that the input is an integer
  • By using htmlspecialchars() on the SQL

Answer: By passing every value as a bound parameter (the ? placeholders) in a prepared statement. Prepared statements send SQL and values separately, so user input is always treated as data, never as code.

What does htmlspecialchars($value, ENT_QUOTES, 'UTF-8') do, and when is it applied?

  • It hashes the value before storing it
  • It validates input on the way in
  • It escapes characters like < and > on OUTPUT so a stored <script> is shown as text, not executed — stopping XSS
  • It removes the value from the database

Answer: It escapes characters like < and > on OUTPUT so a stored <script> is shown as text, not executed — stopping XSS. Escape on output, every time you echo user-controlled data into HTML; the database stores it verbatim but it renders harmless.

In the JSON API milestone, what is the correct pattern for an endpoint response?

  • echo the raw array directly
  • Send a Content-Type: application/json header, set the HTTP status code, and echo json_encode(...)
  • Return the data as HTML
  • Save the data to a file and redirect

Answer: Send a Content-Type: application/json header, set the HTTP status code, and echo json_encode(...). A JSON endpoint sends the application/json header, the right status code, and echoes json_encode() of the data.

In the 'Your Turn' UPDATE, why does the WHERE clause include owner_id = ?

  • To make the query run faster
  • Because owner_id is the primary key
  • To avoid a syntax error
  • So one user can never edit another user's task

Answer: So one user can never edit another user's task. Adding WHERE owner_id = ? scopes the update to the logged-in user, preventing them from editing other people's tasks.

In the deploy checklist, what should happen to display_errors in production?

  • Set to On so users can report errors
  • Set to Off, with errors logged to a file instead
  • Removed from php.ini entirely
  • Set to a custom error message string

Answer: Set to Off, with errors logged to a file instead. In production, display_errors = Off (don't leak stack traces to users) and log errors to a file instead.

What is the rule of thumb the capstone repeats for handling data safely?

  • Encrypt everything twice
  • Trust the client and validate later
  • Validate on the way in, escape on the way out
  • Escape on input, validate on output

Answer: Validate on the way in, escape on the way out. Validate input at the boundary it enters and escape output at the boundary it becomes HTML — two different jobs, do both.

Continue this course

Frequently asked questions

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.

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.

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 (&lt; &gt; 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').

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.

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.