Skip to main content

    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

    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.

    Milestone 1 — schema + a reusable db() connection
    <?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";
    ?>
    Output
    Connected. Tables created: users, tasks
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    Milestone 2 — a tiny front-controller router
    <?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";
    ?>
    Output
    Home: welcome to TaskFlow
    TaskController@index -> list of tasks
    TaskController@store  -> task created
    404 Not Found: GET /nope
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    Milestone 3 — register & login with hashing + sessions
    <?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";
    ?>
    Output
    Registered user #1
    Login OK
    Denied
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    Milestone 4 — validated CRUD, injection-proof and XSS-safe
    <?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";
    }
    ?>
    Output
    #1 [todo] Write the README
    #2 [todo] Ship &lt;script&gt;alert(1)&lt;/script&gt;
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    Milestone 5 — GET /api/tasks returns JSON
    <?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";
    ?>
    Output
    HTTP 200
    {
        "data": [
            {
                "id": 1,
                "title": "Write the README",
                "status": "todo"
            }
        ],
        "count": 1
    }
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    Milestone 6 — the go-live checklist as code
    <?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";
    ?>
    Output
    === 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.
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

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

    🎯 Your turn: complete a task safely
    <?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
    ?>
    Output
    Demo: done
    Replace the two ___ 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.

    🎯 Your turn: write the auth guard
    <?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
    ?>
    Output
    Allowed: user #7
    Redirect to /login
    Use isset(...) 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 to execute([...]). 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 calling session_start(). Start the session at the very top, before any output.
    • Login always fails even with the right password — you compared with == instead of using password_verify(), or you truncated the hash column. Store the full hash in a VARCHAR(255) and verify with password_verify($input, $hash).
    • "Call to a member function prepare() on null" — your db() returned nothing or the connection threw. Set PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION so 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 .env and add it to .gitignore on day one — it's painful to scrub a leaked password from git history later.

    📋 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);

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

    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.

    🎯 Stretch: a safe, bound LIKE search returned as JSON
    <?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
    ?>
    Write 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.

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service