正确做法是用变量路由+前置分组,将版本号作为可选参数提取:Route::group(['prefix' => 'api'], function () { Route::rule(':version//', 'api/:module/:action')->pattern(['version' => 'v[12]']); });

版本号放 URL 路径里怎么配路由
ThinkPHP 默认不识别 v1、v2 这类路径段为版本标识,直接写 Route::get('v1/user', ...) 会和普通路由冲突,且无法复用控制器逻辑。
正确做法是用变量路由 + 前置分组,把版本号作为可选参数提取出来:
Route::group(['prefix' => 'api'], function () {
Route::rule(':version/<module>/<action>', 'api/:module/:action')
->pattern(['version' => 'vd+']);
});
这样 /api/v1/user/list 就能被解析,:version 会传入控制器;注意 pattern 必须加,否则 :version 会匹配任意字符串,导致路由混乱。
-
:version参数默认不会自动注入到控制器方法,需手动从input()或request()->param('version')获取 - 如果只支持
v1和v2,建议把pattern改成v[12],避免意外匹配v999 - 别在
route.php里重复定义多个v1/xxx、v2/xxx路由——维护成本高,且无法统一拦截未支持版本
如何在控制器里按版本分流逻辑
拿到 :version 后不能靠 if-else 硬写,否则控制器很快变成“版本判断器”。应该让版本差异落在服务层或模型层。
立即学习“PHP免费学习笔记(深入)”;
推荐做法:用命名空间隔离 + 工厂加载。比如:
// 在控制器中
$version = request()->param('version', 'v1');
$serviceClass = "app\service\{$version}\UserService";
$service = new $serviceClass();
对应目录结构:app/service/v1/UserService.php 和 app/service/v2/UserService.php。两个类实现相同接口,但行为不同(如 v2 返回字段更多、校验更严)。
- 务必检查
$serviceClass是否真实存在,否则会触发Class not found错误,建议加class_exists()判断并抛出400 Bad Request - 不要把版本判断逻辑塞进
__construct或基类初始化里——会导致所有请求都加载两套逻辑,浪费资源 - 如果只是字段增减,优先用数据组装层(如
Resource类)控制输出,而非拆控制器
Header 里带 version 怎么兼容处理
有些团队坚持用 Accept: application/vnd.myapp.v2+json 或自定义头 X-API-Version: v2,TP 原生不解析这类头,得自己钩子介入。
最稳妥的位置是在中间件里统一提取版本,并写入 Request 对象,后续控制器无需重复判断:
public function handle($request, Closure $next)
{
$version = $request->header('x-api-version', 'v1');
if (!in_array($version, ['v1', 'v2'])) {
throw new HttpException(400, 'Unsupported API version');
}
$request->version = $version;
return $next($request);
}
然后控制器里直接用 $this->request->version。比每次调 header() 更轻量,也避免漏判。
- 别在控制器里用
input()读 header——input()默认只读 GET/POST,header 需显式指定input('header.', 'x-api-version') - 如果同时支持 URL 和 Header 两种方式,以 Header 为准(更符合 REST 规范),URL 版本仅作 fallback
- 注意 Nginx/Apache 可能过滤掉带下划线的 header(如
X-API-Version),生产环境建议用XApiVersion或全大写X-API-VERSION
为什么不能靠模块名区分版本
有人图省事,建 v1、v2 两个模块,再配 Route::module(['v1', 'v2']) ——这会导致路由无法收敛,v1/user 和 v2/user 实际走的是两套完全独立的 dispatch 流程。
问题在于:模块机制会重置控制器命名空间、模板路径、配置加载范围。v2 想复用 v1 的模型或验证规则,就得硬写完整命名空间,失去版本控制本意。
- 模块方式会让
config/下的配置文件无法按版本覆盖(比如 v2 需要不同缓存策略) - 调试时看不到统一入口,
debug日志里路由匹配记录分散,排查 404 很困难 - 升级到 TP8 后模块机制已被弱化,官方文档明确建议用分组+命名空间替代
版本控制的本质是“同一接口的不同契约”,不是“两个独立系统”。路径变量 + 命名空间 + 中间件才是可控、可测、可灰度的组合。


















