PHP 实战:用 PDO 搭一个待办应用

AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。

PHP 8.4

把基础章节拼成一个能跑的应用:一个待办清单,支持添加、完成、删除,数据存 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。

上线前检查

  1. display_errors=0,错误写日志;
  2. 生产环境用 HTTPS,session.cookie_secure=1、session.cookie_httponly=1;
  3. session_regenerate_id(true) 在登录态变化时调用(本项目无登录,可略);
  4. 静态资源与 src/、views/ 不在 Web 根目录下(本例 -t public 保证);
  5. 数据库文件不可通过 URL 访问;
  6. 所有写操作都校验 CSRF;
  7. 列表输出全部 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() 是默认动作。
  • 上线前逐条过安全检查清单,比事后补救便宜。

下一步