Skip to content

PHP Tutorial Practical PHP Projects

Project 3: To-Do List

Lesson 349 of 369 2 min read By Deepak Paswan Updated Oct 5, 2026

Level: beginner to intermediate. A to-do list is the classic CRUD project. You will add tasks, mark them done, delete them, and filter by status.

The table

query.sql
SQL
CREATE TABLE tasks (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(200) NOT NULL,
    done TINYINT(1) NOT NULL DEFAULT 0,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

The repository

TaskRepository.php
PHP
<?php

declare(strict_types=1);

final class TaskRepository
{
    public function __construct(private PDO $pdo) {}

    /** @param "all"|"open"|"done" $filter */
    public function all(string $filter = "all"): array
    {
        $where = match ($filter) {
            "open" => "WHERE done = 0",
            "done" => "WHERE done = 1",
            default => "",
        };

        return $this->pdo->query("SELECT id, title, done FROM tasks $where ORDER BY done ASC, id DESC")->fetchAll();
    }

    public function add(string $title): int
    {
        $stmt = $this->pdo->prepare("INSERT INTO tasks (title, created_at) VALUES (:title, :created)");
        $stmt->execute(["title" => $title, "created" => date("Y-m-d H:i:s")]);

        return (int) $this->pdo->lastInsertId();
    }

    public function toggle(int $id): bool
    {
        $stmt = $this->pdo->prepare("UPDATE tasks SET done = 1 - done WHERE id = :id");
        $stmt->execute(["id" => $id]);

        return $stmt->rowCount() === 1;
    }

    public function delete(int $id): bool
    {
        $stmt = $this->pdo->prepare("DELETE FROM tasks WHERE id = :id");
        $stmt->execute(["id" => $id]);

        return $stmt->rowCount() === 1;
    }

    /** @return array{open: int, done: int} */
    public function counts(): array
    {
        $rows = $this->pdo->query("SELECT done, COUNT(*) AS n FROM tasks GROUP BY done")->fetchAll(PDO::FETCH_KEY_PAIR);

        return ["open" => (int) ($rows[0] ?? 0), "done" => (int) ($rows[1] ?? 0)];
    }
}

function validateTitle(string $title): ?string
{
    $title = trim($title);

    if ($title === "") {
        return "Please write a task.";
    }

    return mb_strlen($title) > 200 ? "A task can have up to 200 characters." : null;
}

done = 1 - done flips 0 to 1 and 1 to 0 inside the database, in one statement. No read-then-write race.

Test it

index.php
PHP
<?php
require __DIR__ . "/TaskRepository.php";

$tasks = new TaskRepository(require __DIR__ . "/db.php");

$a = $tasks->add("Learn PDO");
$b = $tasks->add("Build the to-do list");
$tasks->add("Write tests");

$tasks->toggle($a);                      // done
$tasks->delete($b);

foreach ($tasks->all() as $t) {
    echo ($t["done"] ? "[x] " : "[ ] ") . $t["title"] . "\n";
}

print_r($tasks->counts());
var_dump($tasks->toggle(999));           // no such task
echo validateTitle("   ") . "\n";
var_dump(validateTitle("Buy milk"));
echo count($tasks->all("open")) . " open task(s)";
Output
OUTPUT
[ ] Write tests
[x] Learn PDO
Array
(
    [open] => 1
    [done] => 1
)
bool(false)
Please write a task.
NULL
1 open task(s)

Open tasks come first (ORDER BY done ASC), newest first inside each group.

The page

index.php
PHP
<?php
declare(strict_types=1);

session_start();

require __DIR__ . "/functions.php";       // h(), csrfToken(), csrfCheck()
require __DIR__ . "/TaskRepository.php";

$tasks = new TaskRepository(require __DIR__ . "/db.php");
$error = null;

if ($_SERVER["REQUEST_METHOD"] === "POST") {
    csrfCheck();

    $action = (string) ($_POST["action"] ?? "");
    $id = filter_var($_POST["id"] ?? null, FILTER_VALIDATE_INT);

    switch ($action) {
        case "add":
            $title = trim((string) ($_POST["title"] ?? ""));
            $error = validateTitle($title);

            if ($error === null) {
                $tasks->add($title);
            }
            break;

        case "toggle":
            if ($id !== false) {
                $tasks->toggle($id);
            }
            break;

        case "delete":
            if ($id !== false) {
                $tasks->delete($id);
            }
            break;
    }

    if ($error === null) {
        header("Location: index.php?" . http_build_query(["filter" => $_GET["filter"] ?? "all"]));
        exit;
    }
}

$filter = in_array($_GET["filter"] ?? "all", ["all", "open", "done"], true) ? $_GET["filter"] : "all";
$list = $tasks->all($filter);
$counts = $tasks->counts();
?>
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>To-do list</title></head>
<body>
  <h1>To-do list</h1>

  <form method="post">
    <input type="hidden" name="csrf_token" value="<?= h(csrfToken()) ?>">
    <input type="hidden" name="action" value="add">
    <label for="title">New task</label>
    <input id="title" name="title" maxlength="200" required>
    <button type="submit">Add</button>
    <?php if ($error): ?><span role="alert"><?= h($error) ?></span><?php endif; ?>
  </form>

  <nav aria-label="Filter tasks">
    <a href="?filter=all">All</a> |
    <a href="?filter=open">Open (<?= $counts["open"] ?>)</a> |
    <a href="?filter=done">Done (<?= $counts["done"] ?>)</a>
  </nav>

  <?php if ($list === []): ?>
    <p>Nothing here.</p>
  <?php else: ?>
    <ul>
      <?php foreach ($list as $t): ?>
        <li>
          <form method="post" style="display:inline">
            <input type="hidden" name="csrf_token" value="<?= h(csrfToken()) ?>">
            <input type="hidden" name="id" value="<?= (int) $t["id"] ?>">
            <button name="action" value="toggle" aria-label="Mark as <?= $t["done"] ? "open" : "done" ?>"><?= $t["done"] ? "☑" : "☐" ?></button>
            <span style="<?= $t["done"] ? "text-decoration: line-through" : "" ?>"><?= h($t["title"]) ?></span>
            <button name="action" value="delete" aria-label="Delete <?= h($t["title"]) ?>">Delete</button>
          </form>
        </li>
      <?php endforeach; ?>
    </ul>
  <?php endif; ?>
</body>
</html>

What you practised

  • A repository with all SQL, tested without the page.
  • One page handling several actions with switch and a hidden action field.
  • POST for every change, a CSRF token, and Post/Redirect/Get.
  • An allow-list for the filter value.
  • Accessible buttons with aria-label.

Challenges

  1. Add an edit feature (click a task title to change it).
  2. Add due dates and highlight overdue tasks.
  3. Give each task a user id, so every logged-in user sees only their own tasks (Protecting Pages).
  4. Add a “clear completed” button.
  5. Replace the page reloads with fetch() calls to a small JSON endpoint (see the JSON API project).