PHP RESTful
Resources, methods, status codes, and a framework-free minimal REST implementation.
Core conventions
| Method | Path | Meaning | Status |
|---|---|---|---|
| GET | /users | 列表 | 200 |
| GET | /users/1 | 单个 | 200 / 404 |
| POST | /users | 创建 | 201 + Location |
| PUT | /users/1 | 整体替换 | 200 / 204 |
| PATCH | /users/1 | 部分更新 | 200 / 204 |
| DELETE | /users/1 | 删除 | 204 / 404 |
No verbs in paths. /getUser?id=1 is RPC; /users/1 is REST.
A minimal implementation
<?php
declare(strict_types=1);
header("Content-Type: application/json; charset=utf-8");
$method = $_SERVER["REQUEST_METHOD"];
$path = parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);
$path = rtrim($path, "/") ?: "/";
function json(array $data, int $status = 200): never {
http_response_code($status);
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
$users = [
1 => ["id" => 1, "name" => "Tom"],
2 => ["id" => 2, "name" => "Amy"],
];
if ($path === "/users" && $method === "GET") {
json(["items" => array_values($users)]);
}
if (preg_match("#^/users/(\d+)$#", $path, $m)) {
$id = (int)$m[1];
if (!isset($users[$id])) {
json(["error" => "用户不存在"], 404);
}
if ($method === "GET") {
json($users[$id]);
}
if ($method === "DELETE") {
unset($users[$id]);
http_response_code(204);
exit;
}
}
if ($path === "/users" && $method === "POST") {
$body = json_decode(file_get_contents("php://input"), true) ?: [];
$id = max(array_keys($users)) + 1;
json(["id" => $id, "name" => $body["name"] ?? ""], 201);
}
json(["error" => "未找到路由"], 404);Response for GET /users:
{"items":[{"id":1,"name":"Tom"},{"id":2,"name":"Amy"}]}Response for GET /users/9 (404):
{"error":"用户不存在"}Response for POST /users (201):
{"id":3,"name":"Bob"}Status codes
| Code | Meaning | When |
|---|---|---|
| 200 | OK | 查询成功、更新成功 |
| 201 | Created | 创建成功,带 Location 头 |
| 204 | No Content | 删除或更新成功且无返回体 |
| 400 | Bad Request | JSON 格式错、参数缺失 |
| 401 | Unauthorized | 未登录或 token 无效 |
| 403 | Forbidden | 已登录但无权限 |
| 404 | Not Found | 资源不存在 |
| 405 | Method Not Allowed | 路径对但方法不对 |
| 422 | Unprocessable Entity | 校验失败 |
| 429 | Too Many Requests | 限流 |
| 500 | Internal Server Error | 未捕获异常 |
code: 500 in the body.** Status codes drive CDNs, gateways, monitoring and retries — burying them breaks all of it.Routing
Real projects use regex or trie routers. A method+pattern table is clearest by hand.
<?php
$routes = [
["GET", "#^/users$#", "listUsers"],
["POST", "#^/users$#", "createUser"],
["GET", "#^/users/(\d+)$#", "getUser"],
["PATCH", "#^/users/(\d+)$#", "updateUser"],
["DELETE", "#^/users/(\d+)$#", "deleteUser"],
];
$method = "GET";
$path = "/users/7";
$allowed = [];
$matched = false;
foreach ($routes as [$m, $pattern, $handler]) {
if (!preg_match($pattern, $path, $args)) {
continue;
}
$allowed[] = $m;
if ($m === $method) {
$matched = true;
echo "命中 $handler,参数 " . ($args[1] ?? "-") . "\n";
break;
}
}
if (!$matched) {
echo $allowed ? "405 Allow: " . implode(",", $allowed) : "404 未找到";
}
echo "\n";命中 getUser,参数 7A path match with the wrong method should be **405 with an Allow header**, not 404 — telling the client the URL is right but the method is not.
Versioning
- Path version ——
/api/v1/users, most explicit - Header version ——
Accept: ...;version=1 - Query version ——
?v=1, not recommended
Versioning exists so two versions can run side by side while clients migrate. Skip it if you do not need that.
核心约定
| 方法 | 路径 | 含义 | 状态码 |
|---|---|---|---|
| GET | /users | 列表 | 200 |
| GET | /users/1 | 单个 | 200 / 404 |
| POST | /users | 创建 | 201 + Location |
| PUT | /users/1 | 整体替换 | 200 / 204 |
| PATCH | /users/1 | 部分更新 | 200 / 204 |
| DELETE | /users/1 | 删除 | 204 / 404 |
路径里不要出现动词。/getUser?id=1 是 RPC 风格,/users/1 才是 REST。
最小实现
<?php
declare(strict_types=1);
header("Content-Type: application/json; charset=utf-8");
$method = $_SERVER["REQUEST_METHOD"];
$path = parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);
$path = rtrim($path, "/") ?: "/";
function json(array $data, int $status = 200): never {
http_response_code($status);
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
$users = [
1 => ["id" => 1, "name" => "Tom"],
2 => ["id" => 2, "name" => "Amy"],
];
if ($path === "/users" && $method === "GET") {
json(["items" => array_values($users)]);
}
if (preg_match("#^/users/(\d+)$#", $path, $m)) {
$id = (int)$m[1];
if (!isset($users[$id])) {
json(["error" => "用户不存在"], 404);
}
if ($method === "GET") {
json($users[$id]);
}
if ($method === "DELETE") {
unset($users[$id]);
http_response_code(204);
exit;
}
}
if ($path === "/users" && $method === "POST") {
$body = json_decode(file_get_contents("php://input"), true) ?: [];
$id = max(array_keys($users)) + 1;
json(["id" => $id, "name" => $body["name"] ?? ""], 201);
}
json(["error" => "未找到路由"], 404);GET /users 的响应:
{"items":[{"id":1,"name":"Tom"},{"id":2,"name":"Amy"}]}GET /users/9 的响应(404):
{"error":"用户不存在"}POST /users 的响应(201):
{"id":3,"name":"Bob"}状态码
| 码 | 含义 | 何时用 |
|---|---|---|
| 200 | OK | 查询成功、更新成功 |
| 201 | Created | 创建成功,带 Location 头 |
| 204 | No Content | 删除或更新成功且无返回体 |
| 400 | Bad Request | JSON 格式错、参数缺失 |
| 401 | Unauthorized | 未登录或 token 无效 |
| 403 | Forbidden | 已登录但无权限 |
| 404 | Not Found | 资源不存在 |
| 405 | Method Not Allowed | 路径对但方法不对 |
| 422 | Unprocessable Entity | 校验失败 |
| 429 | Too Many Requests | 限流 |
| 500 | Internal Server Error | 未捕获异常 |
code: 500。** 状态码是给 HTTP 层看的——CDN、网关、监控、重试逻辑全靠它判断,塞进 body 等于让这些基础设施全部失效。路由
真实项目用正则或前缀树路由。手写时按「方法 + 模式」建表最清楚。
<?php
$routes = [
["GET", "#^/users$#", "listUsers"],
["POST", "#^/users$#", "createUser"],
["GET", "#^/users/(\d+)$#", "getUser"],
["PATCH", "#^/users/(\d+)$#", "updateUser"],
["DELETE", "#^/users/(\d+)$#", "deleteUser"],
];
$method = "GET";
$path = "/users/7";
$allowed = [];
$matched = false;
foreach ($routes as [$m, $pattern, $handler]) {
if (!preg_match($pattern, $path, $args)) {
continue;
}
$allowed[] = $m;
if ($m === $method) {
$matched = true;
echo "命中 $handler,参数 " . ($args[1] ?? "-") . "\n";
break;
}
}
if (!$matched) {
echo $allowed ? "405 Allow: " . implode(",", $allowed) : "404 未找到";
}
echo "\n";命中 getUser,参数 7路径匹配但方法不匹配时返回 **405 加 Allow 头**,而不是 404。这让客户端知道「地址是对的,只是方法用错了」。
版本化
- 路径版本 ——
/api/v1/users,最直观,推荐 - 请求头版本 ——
Accept: application/vnd.api+json;version=1 - 查询参数版本 ——
?v=1,不推荐(污染缓存键)
版本化不是为了「显得专业」,而是为了能同时跑两个版本,让老客户端有时间迁移。没这个需求就别加。