PHP 实战:写一个 JSON REST API
AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。
前端分离、移动端、第三方对接都需要 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 结构,绝不把堆栈返回给客户端。