PHP 实战:写一个 JSON REST API

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

PHP 8.4

前端分离、移动端、第三方对接都需要 API。这一篇不引框架,用 PDO + 一个路由表把 CRUD 写规范:状态码对、错误结构统一、输入先校验。

目标与接口

方法 路径 作用 成功状态码
GET /api/posts 列表(分页) 200
GET /api/posts/{id} 单条 200 / 404
POST /api/posts 新建 201 + Location
PUT /api/posts/{id} 全量更新 200 / 404
PATCH /api/posts/{id} 部分更新 200 / 404
DELETE /api/posts/{id} 删除 204 / 404

错误统一:

{ "error": { "code": "validation_failed", "message": "title 不能为空", "fields": { "title": "必填" } } }

入口与路由

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

require __DIR__ . '/../src/bootstrap.php';

header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');

$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?? '/';
$path = rtrim($path, '/') ?: '/';

try {
    route($method, $path, $pdo);
} catch (ApiError $e) {
    respond($e->status, ['error' => $e->payload()]);
} catch (Throwable $e) {
    error_log((string) $e);
    respond(500, ['error' => ['code' => 'internal_error', 'message' => '服务器内部错误']]);
}

route() 用正则匹配带参数的路径:

function route(string $method, string $path, PDO $pdo): void
{
    if (preg_match('#^/api/posts/(\d+)$#', $path, $m)) {
        $id = (int) $m[1];
        match ($method) {
            'GET'    => showPost($pdo, $id),
            'PUT'    => updatePost($pdo, $id, true),
            'PATCH'  => updatePost($pdo, $id, false),
            'DELETE' => deletePost($pdo, $id),
            default  => throw new ApiError(405, 'method_not_allowed', '方法不允许'),
        };
    }

    if ($path === '/api/posts') {
        match ($method) {
            'GET'  => listPosts($pdo),
            'POST' => createPost($pdo),
            default => throw new ApiError(405, 'method_not_allowed', '方法不允许'),
        };
    }

    throw new ApiError(404, 'not_found', '接口不存在');
}

405 响应应带 Allow 头,浏览器与客户端才能判断可用方法。要严谨可以按路径分组设置。

响应助手

<?php
// src/bootstrap.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,
]);

final class ApiError extends RuntimeException
{
    public function __construct(
        public readonly int $status,
        public readonly string $code,
        string $message,
        public readonly array $fields = [],
    ) {
        parent::__construct($message);
    }

    /** @return array<string, mixed> */
    public function payload(): array
    {
        $data = ['code' => $this->code, 'message' => $this->getMessage()];
        if ($this->fields !== []) {
            $data['fields'] = $this->fields;
        }
        return $data;
    }
}

function respond(int $status, mixed $data = null, array $headers = []): never
{
    http_response_code($status);
    foreach ($headers as $name => $value) {
        header("{$name}: {$value}");
    }

    if ($status !== 204 && $data !== null) {
        echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    }
    exit;
}

function json_body(): array
{
    $raw = file_get_contents('php://input') ?: '';
    if ($raw === '') {
        return [];
    }

    try {
        $data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
    } catch (JsonException) {
        throw new ApiError(400, 'invalid_json', '请求体不是合法 JSON');
    }

    if (!is_array($data)) {
        throw new ApiError(400, 'invalid_json', '请求体必须是 JSON 对象');
    }
    return $data;
}

respond() 用 exit 结束请求,避免控制器继续执行。

表结构

CREATE TABLE IF NOT EXISTS posts (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    title      TEXT NOT NULL,
    status     TEXT NOT NULL DEFAULT 'draft',
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL
);

SQLite 起步够用;换 MySQL 时把 AUTOINCREMENT 换成 AUTO_INCREMENT,时间字段改 DATETIME,其余逻辑不变。

校验

function validatePost(array $input, bool $partial = false): array
{
    $fields = [];

    $title = $input['title'] ?? null;
    if ($title === null) {
        if (!$partial) $fields['title'] = '必填';
    } elseif (!is_string($title) || trim($title) === '') {
        $fields['title'] = '不能为空';
    } elseif (mb_strlen($title) > 120) {
        $fields['title'] = '不能超过 120 字';
    }

    $status = $input['status'] ?? null;
    if ($status !== null && !in_array($status, ['draft', 'published'], true)) {
        $fields['status'] = '只能是 draft 或 published';
    }

    if ($fields !== []) {
        throw new ApiError(422, 'validation_failed', '参数校验失败', $fields);
    }

    return array_filter([
        'title'  => is_string($title) ? trim($title) : null,
        'status' => $status,
    ], static fn ($v) => $v !== null);
}

校验失败用 422(语义错误),JSON 语法错误用 400,两者别混——客户端据此决定是提示用户还是修代码。

列表与分页

function listPosts(PDO $pdo): never
{
    $page = filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT) ?: 1;
    $perPage = filter_input(INPUT_GET, 'per_page', FILTER_VALIDATE_INT) ?: 20;
    $page = max(1, $page);
    $perPage = min(100, max(1, $perPage));
    $offset = ($page - 1) * $perPage;

    $sortable = ['id', 'created_at', 'title'];
    $sort = in_array($_GET['sort'] ?? '', $sortable, true) ? $_GET['sort'] : 'id';
    $dir = ($_GET['dir'] ?? 'desc') === 'asc' ? 'ASC' : 'DESC';

    $total = (int) $pdo->query('SELECT COUNT(*) FROM posts')->fetchColumn();

    $stmt = $pdo->prepare("SELECT id, title, status, created_at FROM posts ORDER BY {$sort} {$dir} LIMIT :limit OFFSET :offset");
    $stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
    $stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
    $stmt->execute();

    respond(200, [
        'data' => $stmt->fetchAll(),
        'meta' => [
            'page'     => $page,
            'per_page' => $perPage,
            'total'    => $total,
            'pages'    => (int) ceil($total / $perPage),
        ],
    ]);
}

排序字段走白名单,永远不要直接拼进 SQL;LIMIT/OFFSET 用整数绑定。

读取单条

function findPostOrFail(PDO $pdo, int $id): array
{
    $stmt = $pdo->prepare('SELECT id, title, status, created_at, updated_at FROM posts WHERE id = :id');
    $stmt->execute([':id' => $id]);
    $post = $stmt->fetch();

    if ($post === false) {
        throw new ApiError(404, 'not_found', '文章不存在');
    }
    return $post;
}

function showPost(PDO $pdo, int $id): never
{
    respond(200, ['data' => findPostOrFail($pdo, $id)]);
}

新建

function createPost(PDO $pdo): never
{
    $data = validatePost(json_body());

    $stmt = $pdo->prepare(
        'INSERT INTO posts (title, status, created_at, updated_at) VALUES (:title, :status, :now, :now)'
    );
    $stmt->execute([
        ':title'  => $data['title'],
        ':status' => $data['status'] ?? 'draft',
        ':now'    => date('c'),
    ]);

    $id = (int) $pdo->lastInsertId();
    respond(201, ['data' => findPostOrFail($pdo, $id)], [
        'Location' => "/api/posts/{$id}",
    ]);
}

201 必须带 Location,客户端才知道新资源地址。

更新与删除

function updatePost(PDO $pdo, int $id, bool $full): never
{
    findPostOrFail($pdo, $id);                 // 不存在直接 404
    $data = validatePost(json_body(), !$full);

    if ($data === []) {
        throw new ApiError(422, 'validation_failed', '没有可更新的字段', ['body' => '至少提供一个字段']);
    }

    $sets = [];
    $params = [':id' => $id];
    foreach ($data as $column => $value) {
        $sets[] = "{$column} = :{$column}";
        $params[":{$column}"] = $value;
    }
    $params[':updated'] = date('c');

    $sql = 'UPDATE posts SET ' . implode(', ', $sets) . ', updated_at = :updated WHERE id = :id';
    $pdo->prepare($sql)->execute($params);

    respond(200, ['data' => findPostOrFail($pdo, $id)]);
}

function deletePost(PDO $pdo, int $id): never
{
    $stmt = $pdo->prepare('DELETE FROM posts WHERE id = :id');
    $stmt->execute([':id' => $id]);

    if ($stmt->rowCount() === 0) {
        throw new ApiError(404, 'not_found', '文章不存在');
    }
    respond(204);
}

$sets 的列名来自 validatePost() 的返回键(固定集合),不是用户输入,所以可以拼接;值依然全部用占位符。

curl 验收

curl -s http://localhost:8000/api/posts?page=1\&per_page=5
curl -s -X POST http://localhost:8000/api/posts \
  -H "Content-Type: application/json" \
  -d '{"title":"第一篇","status":"draft"}'
curl -s -i -X PATCH http://localhost:8000/api/posts/1 \
  -H "Content-Type: application/json" -d '{"status":"published"}'
curl -s -i -X DELETE http://localhost:8000/api/posts/1
curl -s -X POST http://localhost:8000/api/posts -H "Content-Type: application/json" -d '{"title":""}'

逐条对照:201 + Location、422 带 fields、204 无响应体、404 是 JSON 而不是 HTML。

常见错误与注意事项

1. 错误也返回 200

前端只能靠解析内容判断成败。4xx/5xx 必须落到状态码上。

2. 出错时输出 HTML 错误页

display_errors=1 或框架错误页会破坏 JSON 格式。API 入口固定 Content-Type: application/json,异常统一走 respond()。

3. 把空数组和空对象混淆

json_decode($raw, true) 对 [] 和 {} 都返回数组,需要区分时用 json_decode($raw) 拿对象再判断。

4. 直接拼 ORDER BY / LIMIT

排序字段白名单,分页参数转整数,别指望预处理救表名。

5. PATCH 与 PUT 混为一谈

PUT 是整体替换(缺省字段应被清空或拒绝),PATCH 是部分更新。本例为了简洁让 PUT 也走部分更新,生产 API 应明确写进文档。

6. 没限制分页大小

per_page=100000 能拖垮数据库。上限设 100,超了直接截断或返回 422。

7. 不做请求体大小限制

超大 JSON 会吃满内存。Web 服务器层限制 client_max_body_size,PHP 层检查 CONTENT_LENGTH。

8. 忘记并发与幂等

网络重试可能重复创建。需要幂等就让客户端带 Idempotency-Key,服务端记录已处理键。

小结

  • 路径正则 + 方法分发,足以支撑小型 REST API。
  • 状态码承担语义:201 带 Location、204 无体、422 带字段错误、405 带 Allow。
  • 输入校验集中在 validatePost(),返回受控的字段集合再拼 SQL。
  • 分页参数转整数并设上限,排序字段走白名单。
  • 所有异常收敛到统一 JSON 结构,绝不把堆栈返回给客户端。

下一步