Build a Todo App with PDO
AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.
Time to assemble the earlier chapters into something that runs: a todo list that can add, complete and delete items, stored in SQLite. The point is not the feature set — it is getting each layer right.
Layout and entry point
todo/
public/index.php
src/db.php
src/helpers.php
views/layout.php
views/list.php
storage/app.db
<?php
// public/index.php
declare(strict_types=1);
require __DIR__ . '/../src/helpers.php';
require __DIR__ . '/../src/db.php';
session_start();
$route = $_GET['r'] ?? 'list';
$method = $_SERVER['REQUEST_METHOD'];
try {
match (true) {
$method === 'GET' && $route === 'list' => showList($pdo),
$method === 'POST' && $route === 'add' => addTodo($pdo),
$method === 'POST' && $route === 'toggle' => toggleTodo($pdo),
$method === 'POST' && $route === 'delete' => deleteTodo($pdo),
default => notFound(),
};
} catch (Throwable $e) {
error_log((string) $e);
http_response_code(500);
echo 'Server error';
}
A ?r= query parameter is the smallest viable router. Every write goes through POST, so link prefetching, crawlers and a browser restoring history can never trigger a change.
Schema
<?php
// src/db.php
declare(strict_types=1);
$pdo = new PDO('sqlite:' . __DIR__ . '/../storage/app.db', null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$pdo->exec(<<<SQL
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)
SQL);
For a demo, creating the table at the entry point is fine. A real project uses a migration script so DDL never runs on a normal request.
Helper functions
<?php
// src/helpers.php
declare(strict_types=1);
function e(?string $value): string
{
return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function render(string $view, array $data = []): void
{
extract($data, EXTR_SKIP);
ob_start();
require __DIR__ . '/../views/' . $view . '.php';
$content = ob_get_clean();
require __DIR__ . '/../views/layout.php';
}
function redirect(string $url): never
{
header('Location: ' . $url, true, 303);
exit;
}
function notFound(): never
{
http_response_code(404);
exit('Not found');
}
function flash(?string $message = null): ?string
{
if ($message !== null) {
$_SESSION['flash'] = $message;
return null;
}
$value = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);
return $value;
}
function csrf_token(): string
{
return $_SESSION['csrf'] ??= bin2hex(random_bytes(32));
}
function csrf_check(): void
{
$sent = $_POST['csrf'] ?? '';
if (!is_string($sent) || !hash_equals($_SESSION['csrf'] ?? '', $sent)) {
http_response_code(419);
exit('Session expired, reload the page and try again');
}
}
redirect() uses 303 so the browser follows up with a GET. flash() reads once and clears, which is exactly what Post/Redirect/Get needs.
Reading and rendering
function showList(PDO $pdo): void
{
$todos = $pdo->query('SELECT id, title, done FROM todos ORDER BY done ASC, id DESC')->fetchAll();
render('list', [
'title' => 'Todos',
'todos' => $todos,
'flash' => flash(),
]);
}
<?php
// views/list.php
/** @var list<array{id:int,title:string,done:int}> $todos */
/** @var string|null $flash */
?>
<h1><?= e($title) ?></h1>
<?php if ($flash !== null): ?>
<p class="flash" role="status"><?= e($flash) ?></p>
<?php endif; ?>
<form method="post" action="?r=add" class="add-form">
<input type="hidden" name="csrf" value="<?= e(csrf_token()) ?>" />
<label for="title">New task</label>
<input id="title" name="title" maxlength="120" required autofocus />
<button type="submit">Add</button>
</form>
<ul class="todo-list">
<?php foreach ($todos as $todo): ?>
<li class="<?= $todo['done'] ? 'done' : '' ?>">
<form method="post" action="?r=toggle" class="inline">
<input type="hidden" name="csrf" value="<?= e(csrf_token()) ?>" />
<input type="hidden" name="id" value="<?= (int) $todo['id'] ?>" />
<button type="submit"><?= $todo['done'] ? 'Undo' : 'Done' ?></button>
</form>
<span><?= e($todo['title']) ?></span>
<form method="post" action="?r=delete" class="inline"
onsubmit="return confirm('Delete this task?')">
<input type="hidden" name="csrf" value="<?= e(csrf_token()) ?>" />
<input type="hidden" name="id" value="<?= (int) $todo['id'] ?>" />
<button type="submit">Delete</button>
</form>
</li>
<?php endforeach; ?>
</ul>
Use <button> inside a form rather than an <a> link for writes: it works with the keyboard, screen readers and without JavaScript. The confirm() call is the lightest possible guard, but it is enough to stop misclicks.
Writing
function addTodo(PDO $pdo): never
{
csrf_check();
$title = trim((string) ($_POST['title'] ?? ''));
if ($title === '') {
flash('Task text cannot be empty');
redirect('/');
}
if (mb_strlen($title) > 120) {
flash('Task text is limited to 120 characters');
redirect('/');
}
$stmt = $pdo->prepare('INSERT INTO todos (title, done, created_at) VALUES (:title, 0, :created)');
$stmt->execute([':title' => $title, ':created' => date('c')]);
flash('Added');
redirect('/');
}
function toggleTodo(PDO $pdo): never
{
csrf_check();
$id = filter_input(INPUT_POST, 'id', FILTER_VALIDATE_INT);
if ($id === false || $id === null) {
flash('Invalid parameter');
redirect('/');
}
$stmt = $pdo->prepare('UPDATE todos SET done = CASE done WHEN 1 THEN 0 ELSE 1 END WHERE id = :id');
$stmt->execute([':id' => $id]);
redirect('/');
}
function deleteTodo(PDO $pdo): never
{
csrf_check();
$id = filter_input(INPUT_POST, 'id', FILTER_VALIDATE_INT);
if ($id === false || $id === null) {
flash('Invalid parameter');
redirect('/');
}
$pdo->prepare('DELETE FROM todos WHERE id = :id')->execute([':id' => $id]);
flash('Deleted');
redirect('/');
}
filter_input returns the integer, false when validation fails, and null when the parameter is absent. Handle both failure cases — a === false check alone lets the missing case through.
Layout
<?php
// views/layout.php
/** @var string $content */
/** @var string $title */
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><?= e($title) ?></title>
<style>
body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 3rem auto; padding: 0 1rem; }
.inline { display: inline; }
.done span { text-decoration: line-through; opacity: .6; }
.flash { padding: .5rem .75rem; background: #f0fdf4; border-left: 3px solid #10b981; }
</style>
</head>
<body>
<?= $content ?>
</body>
</html>
$content is printed unescaped because it is already-rendered HTML; every other value goes through e().
Running it locally
php -S localhost:8000 -t public
storage/ must be writable. SQLite creates app.db on first use — add it to .gitignore.
Pre-deploy checklist
display_errors=0, errors written to a log.- HTTPS in production, with
session.cookie_secure=1andsession.cookie_httponly=1. - Call
session_regenerate_id(true)whenever the login state changes (this project has no login, so it is skipped). src/,views/and storage live outside the web root —-t publicguarantees that here.- The database file must not be reachable by URL.
- Every write checks the CSRF token.
- Every list item is printed through
e().
Common mistakes
1. Triggering writes from a GET link
<a href="?r=delete&id=3"> gets prefetched, crawled, or replayed from history. Writes are POST only.
2. No CSRF check
A third-party page can make a logged-in user submit a form. Validate the token on every write, and compare with hash_equals to avoid timing leaks.
3. Rendering directly after a POST
A refresh resubmits the form. Always Post/Redirect/Get: redirect() when the work is done.
4. Passing an unvalidated id into SQL
Even with prepared statements, confirm it is an integer: filter_input plus a cast.
5. Storing done as a string
SQLite has no boolean type. Use 0/1 integers and cast on the way out, so "0" never reads as truthy.
6. One list shared by every visitor
With no user table, all visitors see the same rows. Add user_id and filter every query once accounts exist.
7. Forgetting session_start()
$_SESSION is empty, so CSRF validation always fails. Start the session once, at the entry point.
Summary
- One entry point routes everything; writes accept
POSTonly and verify CSRF. - All SQL uses prepared statements; integer parameters are filtered first.
- PRG plus flash messages handle resubmission and user feedback.
- Views only print, with
e()as the default action. - Walk the security checklist before deploying — it is cheaper than an incident.
Next: the same data layer exposed as a JSON API with proper status codes.