用 accept header 实现版本路由最干净,即通过 accept: application/vnd.myapp.v2+json 传递版本,保持路径统一为 /api/users,再由中间件解析并注入版本上下文,避免硬编码路由和重复解析。

用 Accept Header 实现版本路由最干净
PHP API 版本控制不是靠改 URL 路径(比如 /v1/users),而是优先用 HTTP Accept 头,比如 Accept: application/vnd.myapp.v2+json。这样接口路径不变,语义清晰,也方便客户端做灰度或回滚。
常见错误是把版本硬编码进路由——结果一加 v3 就得复制整套控制器、中间件、测试用例,维护成本翻倍。
- 用 Laravel 时,在路由定义里不写
v1,而是统一走/api/users,再在中间件里解析Accept头 - 用原生 PHP 或 Slim 时,别在
$app->get('/v1/users')里写死,改用$app->get('/api/users')+ 请求头判断 - 注意
Accept值必须带vnd.前缀(如vnd.myapp.v1+json),否则和标准 MIME 类型冲突,某些网关或 CDN 会直接拦截
Router::group() + 中间件提取版本号
Laravel 用户最容易踩的坑是:在中间件里用 $request->header('Accept') 手动 parse,但没做缓存或 fallback,导致每次请求都正则匹配,性能掉得明显。
推荐做法是用路由分组 + 自定义中间件提前注入版本上下文:
立即学习“PHP免费学习笔记(深入)”;
- 在
routes/api.php里定义统一入口:Route::middleware(['parse.version'])->group(function () { ... }); - 中间件里只做一次解析,把版本存到
$request->attributes->set('api_version', 'v2'),后续控制器直接读取 - 别在每个控制器方法里重复调用
str_contains()或preg_match(),容易漏掉大小写或空格处理 - 如果用了 API 文档工具(如 Swagger),记得在
openapi.yaml里声明Accept支持的值,否则生成的 SDK 默认不带这个头
URL 路径带版本仅用于降级兼容
路径里放 v1 不是错,但只该用在「老客户端无法发 Header」这种真实约束下,比如嵌入式设备、旧版 iOS App、或第三方系统对接。
这时候不能让 /v1/ 和 /v2/ 指向两套完全独立的代码,否则很快失控。
- 所有版本路由最终都 dispatch 到同一个控制器,只是传入不同
$version参数 - 用策略模式组织业务逻辑:比如
UserResponseV1Strategy和UserResponseV2Strategy,避免 if-else 堆砌 - 别让
/v1的响应结构和/v2差太多——字段重命名、嵌套层级变化都会让客户端解析失败,这不是版本升级,是接口重做 - 记录
/v1的访问频率,设个告警阈值,过期版本该下线就下线,别留着当“历史文物”
Header 和 Query 同时存在时以哪个为准?
有些团队为了“兼容性”,允许客户端既传 Accept: v2,又加 ?version=v2。这等于主动放弃一致性,调试时永远不知道当前走的是哪条分支。
必须明确优先级,并在文档和日志里体现:
- 我们定的规则是:Header > Query > 默认版本(
v1) - 中间件里一旦从 Header 解析出有效版本,就忽略 Query 参数,且不记录 warning;但如果 Header 无效、Query 有效,则记录一条
deprecated_query_version_used日志 - 测试时重点覆盖边界情况:空 Header、非法格式(如
v-1)、大小写混用(V2)、多个Accept值(text/html, application/vnd.myapp.v2+json) - 别忘了检查
$_SERVER['HTTP_ACCEPT']在 Nginx/FastCGI 下是否被截断或转小写——某些配置会强制 lower-case 所有 header











