PHP 实战:用 PDO 搭一个待办应用
AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。
把基础章节拼成一个能跑的应用:一个待办清单,支持添加、完成、删除,数据存 SQLite。目标不是堆功能,而是每一层都写对。
目录与入口
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 '服务器错误';
}
用 ?r= 做路由是最小可行方案,之后的章节再换成真正的路由表。所有写操作都走 POST,避免预取或爬虫误触发。
建表
<?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);
演示项目里建表放在入口;真实项目用迁移脚本,避免每次请求都执行 DDL。
辅助函数
<?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('页面不存在');
}
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('会话已过期,请刷新页面重试');
}
}
redirect 用 303,让浏览器把后续请求改成 GET;flash 一次性读取后清空,正好配合 PRG(Post/Redirect/Get)。
读取与渲染
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,
'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">新任务</label>
<input id="title" name="title" maxlength="120" required autofocus />
<button type="submit">添加</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'] ? '撤销' : '完成' ?></button>
</form>
<span><?= e($todo['title']) ?></span>
<form method="post" action="?r=delete" class="inline"
onsubmit="return confirm('删除这条任务?')">
<input type="hidden" name="csrf" value="<?= e(csrf_token()) ?>" />
<input type="hidden" name="id" value="<?= (int) $todo['id'] ?>" />
<button type="submit">删除</button>
</form>
</li>
<?php endforeach; ?>
</ul>
用 button 而不是 <a> 触发写操作,键盘与辅助技术都能正常使用;confirm 只是最轻的确认手段。
写入
function addTodo(PDO $pdo): never
{
csrf_check();
$title = trim((string) ($_POST['title'] ?? ''));
if ($title === '') {
flash('任务内容不能为空');
redirect('/');
}
if (mb_strlen($title) > 120) {
flash('任务内容不能超过 120 字');
redirect('/');
}
$stmt = $pdo->prepare('INSERT INTO todos (title, done, created_at) VALUES (:title, 0, :created)');
$stmt->execute([':title' => $title, ':created' => date('c')]);
flash('已添加');
redirect('/');
}
function toggleTodo(PDO $pdo): never
{
csrf_check();
$id = filter_input(INPUT_POST, 'id', FILTER_VALIDATE_INT);
if ($id === false || $id === null) {
flash('参数不合法');
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('参数不合法');
redirect('/');
}
$pdo->prepare('DELETE FROM todos WHERE id = :id')->execute([':id' => $id]);
flash('已删除');
redirect('/');
}
filter_input 的返回值:合法整数、false(校验失败)、null(参数不存在)。两种失败都要处理,别只判 false。
布局
<?php
// views/layout.php
/** @var string $content */
/** @var string $title */
?>
<!doctype html>
<html lang="zh-CN">
<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 不转义,因为它是模板渲染结果;其余变量一律 e()。
本地运行
php -S localhost:8000 -t public
storage/ 目录要可写。SQLite 首次运行会自动创建 app.db,记得把它加入 .gitignore。
上线前检查
display_errors=0,错误写日志;- 生产环境用 HTTPS,
session.cookie_secure=1、session.cookie_httponly=1; session_regenerate_id(true)在登录态变化时调用(本项目无登录,可略);- 静态资源与
src/、views/不在 Web 根目录下(本例-t public保证); - 数据库文件不可通过 URL 访问;
- 所有写操作都校验 CSRF;
- 列表输出全部
e()。
常见错误与注意事项
1. 写操作直接用 GET 链接
<a href="?r=delete&id=3"> 会被预取、被爬虫、被浏览器历史重放。写操作只用 POST。
2. 没有 CSRF 校验
第三方页面可以诱导已登录用户提交表单。令牌要每次校验,且用 hash_equals 防时序攻击。
3. 表单提交后直接渲染
刷新会重复提交。一律 PRG:处理完 redirect()。
4. id 不校验就进 SQL
即使使用预处理,也要确认它是整数:filter_input + 类型转换。
5. done 用字符串存
SQLite 没有布尔类型,用 0/1 整数并在读取时转换,避免 "0" 被当成真值。
6. 多用户共用一个清单
没有用户体系时,所有访问者共享数据。要区分就加 user_id 并在每个查询里过滤。
7. 忘记 session_start()
$_SESSION 为空,CSRF 校验永远失败。入口处统一开启。
小结
- 入口集中路由,写操作只接受
POST并校验 CSRF。 - 所有 SQL 用预处理,
id这类整数参数先过滤。 - PRG + 闪存消息解决重复提交与提示。
- 视图只负责输出,
e()是默认动作。 - 上线前逐条过安全检查清单,比事后补救便宜。