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
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
<?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
<?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)";
[ ] 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
<?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
switchand a hiddenactionfield. - POST for every change, a CSRF token, and Post/Redirect/Get.
- An allow-list for the filter value.
- Accessible buttons with
aria-label.
Challenges
- Add an edit feature (click a task title to change it).
- Add due dates and highlight overdue tasks.
- Give each task a user id, so every logged-in user sees only their own tasks (Protecting Pages).
- Add a “clear completed” button.
- Replace the page reloads with
fetch()calls to a small JSON endpoint (see the JSON API project).