Route Forge — 功能规格说明书
对外功能承诺,所有实现都应对照此文档验证。
1. 产品概述
Route Forge for Laravel 是 Laravel 命名路由的全链路后端解决方案,提供:
- 路由分级(tier)分配:三种互相兼容的分配方式(显式
->tier()宏、Route::group透传、配置文件批量匹配) - 五级优先级:显式标注 > 分组继承 > 自定义回调 > 配置匹配 > unassigned 兜底
- 元信息端点:按层级返回路由元信息(名称 + URI + method + 参数),供前端按需懒加载
- 摘要端点:返回所有层级概览与全局配置,供前端初始化自动发现
- 统一缓存:所有层级端点与摘要端点共享同一 TTL 配置,支持多种缓存驱动
- Artisan 命令:查看层级分配结果、生成 TS 类型声明文件
2. 目标用户
| 画像 | 典型场景 |
|---|---|
| Laravel 全栈开发者 | Laravel + Vue/React SPA 项目,想让前端调用 API 更优雅 |
| 大型 SPA 项目维护者 | 接口数 > 100,首屏性能敏感 |
| 多角色系统开发者 | 有 admin/user/guest 等多种权限级别 |
| TypeScript 重度用户 | 要求前后端全链路类型安全,需要后端生成 TS 类型声明 |
3. 后端功能
3.1 核心能力
Route Forge 提供三种互相兼容的路由层级分配方式,任选其一或组合使用。三层级名(如 admin/manage/client、public/user/admin
)完全由项目自定义,包不预设固定层级(见 DESIGN.md §6.2)。
3.1.1 定义路由时显式标记(->tier() 宏)
通过宏 tier() 在定义路由时显式标记所属层级,链式调用对资源路由同样生效:
Route::post('/auth/login', [AuthController::class, 'login'])
->name('auth.login')
->tier('public');
Route::resource('users', UserController::class)
->tier('manage');
Route::apiResource('posts', PostController::class)
->tier('manage');
Route::singleton('profile', ProfileController::class)
->tier('client');
宏通过 ServiceProvider 注册到 Illuminate\Routing\Route,仅向 action 数组写入一个 tier 字段,零侵入:
Route::macro('tier', function (string $tier) {
$this->action['tier'] = $tier;
return $this;
});
资源路由(resource / apiResource / singleton / apiSingleton)返回的 Pending 注册对象同样注册了 tier() 宏:tier 暂存于资源 options,注册时经 ForgeResourceRegistrar 写入每条资源路由的 action,语义与单条路由的显式 ->tier() 完全一致(包括「显式标注优先于 group 透传」,见 §3.1.4)。非法层级名在链式调用时立即抛 UnknownLevelException。
3.1.2 配置文件按规则批量分配
config/forge.php 中按 match 规则把路由批量匹配到对应层级,无需逐条标注:
// config/forge.php
return [
'levels' => [
'public' => [
'description' => '公共接口(无需登录)',
'match' => [
'prefix' => ['auth', 'public'],
'middleware' => [],
],
'load' => 'eager',
],
'client' => [
'description' => '客户端用户接口',
'match' => [
'prefix' => ['client'],
'middleware' => ['auth'],
],
'load' => 'lazy',
],
'manage' => [
'description' => '运营管理接口',
'match' => [
'prefix' => ['manage'],
'middleware' => ['auth', 'manage'],
],
'load' => 'lazy',
],
'admin' => [
'description' => '系统管理接口',
'match' => [
'prefix' => ['admin'],
'middleware' => ['auth', 'admin'],
'middleware_match' => 'all', // 要求同时包含 auth 和 admin
],
'load' => 'lazy',
'endpoint_middleware' => ['auth', 'admin'], // 访问 /_forge/routes/admin 需要 auth + admin
],
],
'endpoint_prefix' => '/_forge/routes', // 路由元信息对外端点前缀
'endpoint_middleware' => [], // 摘要端点中间件,null 或空数组 = 不限制
'cache_ttl' => 3600, // 统一缓存 TTL(秒),null=不缓存
'cache_driver' => null, // null=使用默认缓存驱动
'strict_mode' => false, // 严格模式:未命中层级即抛异常
'classifier' => null, // 自定义分类回调,签名 fn(Route $r): ?string
];
匹配规则:
prefix:路由 URI 命中任意一个前缀即归入此层级(支持多个)。middleware:路由中间件集合按middleware_match规则匹配(详见下方「中间件匹配模式」)。prefix与middleware之间是 OR(任一命中即归入)关系:路由命中任意一个prefix前缀即归入此层级,即使其不满足middleware条件;反之,中间件命中而 前缀不命中同样归入。⚠ 若你期望「前缀与中间件同时满足才归入」,请只使用middleware规则(配合middleware_match)表达,或改用classifier回调—— 不要在prefix上寄托 AND 语义。- 显式
->tier()标记优先级最高,覆盖配置匹配结果(见 3.1.4)。 - 多个层级同时命中时,按
levels数组定义顺序取最后一个(后定义覆盖前定义,与Route::group内层覆盖外层的语义一致)。因此把新层级追加到数组末尾即可生效,无需改动已有层级;但反过来说, 若某条新规则与已有层级冲突命中且你希望旧层级继续优先,则必须把新层级放到旧层级之前—— 顺序即优先级,越靠后越优先。 - 全部未命中:
strict_mode=true抛RouteTierNotAssignedException;strict_mode=false时归入unassigned特殊层级(唯一的兜底机制,见 §3.1.4)。 classifier回调优先级介于「显式->tier()/Route::grouptier」与「配置 match」之间(完整五级优先级见 §3.1.4),用于实现复杂自定义分类逻辑(如基于 Controller 命名空间归类)。 注意:由于显式->tier()会直接返回不继续匹配,classifier 实际无法覆盖显式 tier,仅对未显式标注的路由生效。返回的层级名必须在levels配置中存在,否则抛UnknownClassifierTierException(无论strict_mode是否开启)。
⚠️
unassigned是保留层级名:它是未命中任何层级的路由的兜底特殊层级(见 §3.1.4 / §3.1.5),不能用作levels中的自定义层级名。若在levels中定义了名为unassigned的层级,它会被当作特殊层级处理——其match、description、endpoint_middleware等配置全部失效且无任何提示,请勿使用。
中间件匹配模式(middleware_match)
每个层级可在 match 中配置 middleware_match,控制 middleware 数组的匹配逻辑。不配置时默认 'any'。
简单模式:
| 值 | 含义 |
|---|---|
| 'any'(默认) | 路由中间件集合包含 middleware 数组中任意一项即命中(OR 逻辑) |
| 'all' | 路由中间件集合包含 middleware 数组中全部项才命中(AND 逻辑) |
高级模式(数组 DNF 结构):
以 middleware 数组索引(从 0 开始)为操作数,用嵌套数组表达布尔逻辑——内层数组为 AND,外层数组为 OR(析取范式 / DNF):
| 配置示例 | 等价逻辑 |
|---|---|
| [[0, 1]] | middleware[0] AND middleware[1] |
| [[0], [1]] | middleware[0] OR middleware[1] |
| [[0, 1], [2]] | (middleware[0] AND middleware[1]) OR middleware[2] |
| [[0], [1, 2]] | middleware[0] OR (middleware[1] AND middleware[2]) |
// 示例 1:admin 层级要求同时有 auth 和 admin 中间件(AND)
'admin' => [
'match' => [
'prefix' => ['admin'],
'middleware' => ['auth', 'admin'],
'middleware_match' => 'all',
],
'load' => 'lazy',
],
// 示例 2:复杂条件 (有 auth 且有 admin) 或 (有 super_admin)
'admin' => [
'match' => [
'prefix' => ['admin'],
'middleware' => ['auth', 'admin', 'super_admin'],
'middleware_match' => [[0, 1], [2]],
],
'load' => 'lazy',
],
选择 DNF 数组而非字符串表达式的原因:PHP 原生数组无需额外解析器,写错时 PHP 直接报类型错误,不会静默失败。DNF 可表达所有布尔组合,覆盖实际使用场景。
3.1.3 路由分组分配层级(Route::group 透传)
在 Route::group 上支持 tier 选项,整组路由继承层级,避免重复标注。命名空间、中间件、前缀等 Laravel 原生 group 选项继续正常工作:
Route::group([
'prefix' => 'admin',
'middleware' => ['auth', 'admin'],
'tier' => 'admin', // ← 新增选项
], function () {
Route::get('/users', [AdminUserController::class, 'index'])
->name('admin.users.index');
Route::post('/users', [AdminUserController::class, 'store'])
->name('admin.users.store');
// 组内所有路由自动归属 admin 层级
});
嵌套 group 行为(与 Laravel 中 middleware 合并策略一致):
Route::group(['tier' => 'admin'], function () {
Route::get('/a', ...); // admin
Route::group(['tier' => 'manage'], function () {
Route::get('/b', ...); // manage(内层覆盖外层)
});
});
实现方式:register 阶段把容器中的 router 单例重绑为 ForgeRouter(继承 Laravel Router,覆盖
group 属性合并逻辑),tier 透传到组内每条路由的 action,等价于自动给组内每条路由调用
->tier()。分组标记与单条显式 ->tier() 相比,单条优先级更高。
⚠️ 尾部链式写法不受支持:
Route::group([...], fn)->tier('x')不会将tier应用于该组。 Laravel 的group()返回时组内路由已注册完毕、组属性已出栈,其后链式创建的 Registrar 属性无消费方, 会被静默丢弃。Forge 对此场景做运行时检测:持有属性却从未注册 group/路由的 Registrar 被销毁时, 向 Laravel 日志写入一条告警(含被丢弃的属性名与正确写法提示)——默认warning级别;strict_mode=true时升级为error级别(不在析构中抛异常:PHP 析构期间抛异常在栈展开场景会 直接致命错误且无法被 catch,日志告警更可靠;DiscardedRegistrarAttributesException/RF_BE_007保留供兼容,不再自动抛出)。 组级tier只支持两种写法:数组选项Route::group(['tier' => ...], fn)与前置链式Route::tier(...)->group(fn)。
3.1.4 层级分配优先级
当多种分配方式并存时,按以下优先级决定一条路由的最终层级(高优先级覆盖低优先级):
- 显式
->tier()调用(最高):直接返回,不继续后续匹配 Route::group的tier选项(继承自最近一层 group,内层覆盖外层)classifier自定义回调返回非 null 值- 配置文件
match规则匹配(受middleware_match控制) - 兜底:归入
unassigned特殊层级(经层级端点获取,见 §3.1.5 / §3.1.6)
优先级设计意图:显式标注胜过隐式分组,分组胜过全局规则,全局规则胜过兜底。 未标记路由仍可被前端调用,只需通过摘要端点中的
unassigned特殊层级发现, 并经其层级端点(GET /{endpoint_prefix}/unassigned)获取明细。 兜底机制只有 unassigned 一种(早期版本的fallback_level配置因与之语义重复已移除)。⚠️ 安全考量:未标记路由会通过
unassigned层级端点暴露路由名和 URI 模板。生产环境建议配合strict_mode=true或显式标记所有路由,避免信息泄露。
3.1.5 层级元信息查询端点
包在 endpoint_prefix(默认 /_forge/routes)下注册端点,按层级返回路由元信息(名称 + URI + method + 参数定义),供前端按需懒加载:
GET /_forge/routes/{level} # 返回该层级下所有命名路由的元信息
{level} 支持特殊值 unassigned:返回所有未命中任何层级的命名路由,响应结构与已定义层级完全一致。
内部路由排除规则:包自身端点路由(forge.routes.* / forge.manager.*)与框架内部路由(如 Laravel 12+ 的 storage.*)不属于用户业务路由,在所有元信息端点扫描中一律排除——不出现在任何层级端点、unassigned 明细、摘要 route_count 统计中,与 route:forge:list / route:forge:types / 管理器页面的过滤口径一致。这一规则同时保证 strict_mode=true 时包自身路由(永远不带 tier)不会触发 RF_BE_001,严格模式仅校验用户业务路由。
另:routes 字段为按路由名索引的对象;层级下无路由时序列化为 {}(而非 [])。
返回示例:
{
"level": "admin",
"routes": {
"admin.users.index": {
"uri": "admin/users",
"methods": [
"GET",
"HEAD"
],
"parameters": [],
"parameter_defaults": {}
},
"admin.users.show": {
"uri": "admin/users/{user}",
"methods": [
"GET",
"HEAD"
],
"parameters": [
"user"
],
"parameter_defaults": {}
},
"admin.posts.index": {
"uri": "admin/posts/{page?}",
"methods": [
"GET",
"HEAD"
],
"parameters": [
"page"
],
"parameter_defaults": {
"page": "1"
}
}
}
}
缓存:所有层级端点与摘要端点统一使用 cache_ttl 配置项控制 TTL(单位秒,null 不缓存,0 永久缓存)。
⚠ cache_ttl: 0 遵循 Laravel Cache TTL 惯例(永久缓存),非 HTTP Cache-Control: max-age=0 含义。Route Forge 缓存仅通过包内部管理(Cache facade / 配置的 cache_driver),不使用 HTTP 响应头。
端点中间件保护(endpoint_middleware)
每个层级可配置 endpoint_middleware 字段,控制访问该层级元信息端点时要求的中间件。未配置或为空时不限制访问:
'admin' => [
// ... match / load 配置 ...
'endpoint_middleware' => ['auth', 'admin'], // GET /_forge/routes/admin 需要 auth + admin 中间件
],
'public' => [
// ... match / load 配置 ...
// 未配置 endpoint_middleware → GET /_forge/routes/public 无中间件限制
],
摘要端点(GET /_forge/routes)受顶层 endpoint_middleware 配置保护(见 §5)。
实现方式:按层级注册独立路由(方案 B),每个层级路由直接挂载对应的中间件。未配置 endpoint_middleware 的层级不附加任何中间件,保持原有行为。
3.1.6 层级摘要端点
包同时注册一个摘要端点,返回所有层级的概览信息与后端全局配置,供前端初始化时自动发现层级、读取后端配置:
GET /_forge/routes # 返回所有层级摘要 + 全局配置
返回示例:
{
"schemeVersion": 1,
"levels": {
"public": {
"description": "公共接口(无需登录)",
"load": "eager",
"route_count": 12,
"route": {
"uri": "/_forge/routes/public",
"methods": ["GET", "HEAD"]
}
},
"client": {
"description": "客户端用户接口",
"load": "lazy",
"route_count": 45,
"route": {
"uri": "/_forge/routes/client",
"methods": ["GET", "HEAD"]
}
},
"manage": {
"description": "运营管理接口",
"load": "lazy",
"route_count": 38,
"route": {
"uri": "/_forge/routes/manage",
"methods": ["GET", "HEAD"]
}
},
"admin": {
"description": "系统管理接口",
"load": "lazy",
"route_count": 27,
"route": {
"uri": "/_forge/routes/admin",
"methods": ["GET", "HEAD"]
}
},
"unassigned": {
"description": "未命中任何层级的路由",
"load": "lazy",
"route_count": 1,
"route": {
"uri": "/_forge/routes/unassigned",
"methods": ["GET", "HEAD"]
}
}
},
"config": {
"strict_mode": false,
"endpoint_prefix": "/_forge/routes",
"url_prefix": "https://api.example.com/v1",
"cache_ttl": 3600
}
}
字段说明:
schemeVersion:摘要端点响应格式版本号,默认1。后续迭代引入不兼容的格式变更时递增,前端应据此字段识别格式版本并做兼容处理。levels:各层级摘要。description层级描述、load加载策略(eager/lazy)、route_count该层级路由数量、route该层级元信息端点的请求信息(uri+methods),前端可据此直接构造请求获取该层级的全量路由数据。unassigned:特殊层级,与已定义层级结构完全一致,汇总所有未命中任何层级的命名路由。其路由明细不在摘要中内联返回,前端按route字段另行请求GET /{endpoint_prefix}/unassigned获取(见 §3.1.5)。
config:后端全局配置摘要。前端初始化时读取此字段作为最高优先级配置源(后端为权威值,覆盖前端本地配置)。当前包含strict_mode、endpoint_prefix、url_prefix和cache_ttl,后续版本可扩展。endpoint_prefix:下发值经规范化(保证前导/、去除尾部/),与端点实际注册路径 (层级端点 / 摘要端点)完全一致,前端可直接拼接使用;自定义配置如forge/routes/也会以/forge/routes形式下发。cache_ttl:统一为int|null(null不缓存)。经FORGE_CACHE_TTL环境变量配置时也会 转成整数下发,保证前后端契约类型稳定;负值归一化为null(与 §3.1.5 缓存策略中 「负值视为 null」的实际行为一致)。url_prefix:应用的路由前缀,支持两种格式——完整 URL(含协议和域名,如https://api.example.com/v1)或仅路径前缀(如/api/v1)。未配置时为null,前端视为无前缀。
摘要端点同样受 cache_driver 与 cache_ttl 控制缓存。
3.1.7 路由别名(forgeAlias / aliases)
路由改名迭代(如 admin.users.index → admin.members.index)时,别名机制让前端调用方无需修改路由名即可继续工作:别名作为额外键注入目标路由所在层级的元信息,与真实路由名并存,指向完全一致的元信息。
两种用法定位(均可长期使用):
- 改名迁移过渡:改名后声明旧名,前端零改动;改名稳定后清理别名,恢复单名。
- 长期稳定对外名:给易变路由固定一个对外调用名(键),真实路由名仅作内部实现。 此后路由改名、甚至跨层级迁移,只需更新别名映射的目标值,前端永不感知。 推荐约定:前端代码统一只调用别名,真实路由名不出现在前端代码中。
// 长期稳定对外名示例:前端永远调用 client.orders.list
// aliases: ['client.orders.list' => 'client.orders.index']
// 需求迭代把路由改名为 v2、并迁移到 manage 层级——前端零改动:
Route::put('/manage/orders/v2/{id}', [OrderController::class, 'updateV2'])
->name('manage.orders.update')
->tier('manage');
// 仅更新一行映射即可:
// aliases: ['client.orders.list' => 'manage.orders.update']
声明通道(二选一或并用):
// 通道一:路由宏(显式,优先级高于 config)——改名时在路由定义处就近声明
Route::get('/admin/members', [MemberController::class, 'index'])
->name('admin.members.index')
->tier('admin')
->forgeAlias('admin.users.index'); // 可一次声明多个旧名
// 通道二:config/forge.php 集中声明(批量迁移与长期稳定对外名都建议集中在此维护)
'aliases' => [
'admin.users.index' => 'admin.members.index', // 键=别名(旧名),值=真实路由名(新名)
],
合并与冲突规则(对齐 §3.1.4 的显式 > 配置哲学):
- 同一别名同时经宏与 config 声明时,宏(显式)优先;config 中目标不同则记录警告。
- 别名与真实路由名撞车时,真实路由优先,别名声明被忽略并记录警告(宏 / config 两通道同规则;
此前宏通道缺失此检查,撞车别名会静默覆盖端点元信息中真实路由的条目)。
route:forge:list表格中被忽略的撞车声明以红色行展示(仅 table 模式;不进入--json的 routes 与端点元信息)。 - 别名指向的路由名不存在(悬空,常见于路由删除后忘记同步映射)→ 抛
AliasTargetException(RF_BE_008),fail-fast。 - 同一别名在多条路由上重复宏声明:先声明者优先,重复的记录警告(不视为撞车,别名仍生效)。
- 未命名路由上的
->forgeAlias()声明被忽略并记录警告(别名跟随目标路由的命名元信息注入,无名路由无元信息可挂载):不注入别名条目,但route:forge:list的 warnings 中提示补->name(...),避免声明者误以为别名已生效。
行为细节:
- 别名条目出现在目标路由所在层级的端点响应中(含
unassigned特殊层级),元信息(uri/methods/parameters/parameter_defaults)与目标路由完全一致(纯复制,无附加标记字段)——对前端而言别名就是一个真实存在的路由名,校验与类型推断自然通过,前端零改动。 - 摘要端点
route_count计入别名,与层级端点实际返回的routes键数量保持一致。 route:forge:types为别名生成与目标一致的类型条目,TS 侧旧名仍合法。route:forge:list显示别名条目(alias_of字段 /Alias Of列,见 §3.2),支持--aliases过滤。- 管理器页面(§3.3)为别名条目打「别名」标并显示指向。
- 别名是元信息层概念:不参与层级解析(§3.1.4)、不受
strict_mode影响、旧 URI 本身不可访问;解析结果随扫描进入缓存(§3.1.5 缓存策略)。 - 资源路由不支持别名(
Route::resource(...)->forgeAlias()无定义——资源路由一次生成多条命名路由,别名指向存在歧义;如需别名请在展开后的具体路由上声明)。
清理时机只针对用法一:迁移过渡型别名应在改名稳定后清理(
route:forge:list --aliases查看、grepforgeAlias);用法二的长期稳定对外名不需要也不会过期——它是路由名与对外契约之间的解耦层。schemeVersion不因别名递增——routes 多出条目对既有前端无破坏,属向后兼容增量。
3.1.8 首页内嵌摘要(Blade 指令 @forgeSummary)
对「Laravel 服务端渲染 HTML、JS 只在浏览器执行」的首页,可选地把摘要端点的返回值直接内嵌进 HTML,让 @route-forge/core 在浏览器初始化时读内嵌数据、跳过首屏的一次摘要 HTTP 往返。这是摘要端点之外的可选投递方式,不改变既有端点契约(DESIGN §6.3)。
用法:在首页/布局的 <head> 内、早于前端 bundle 处书写指令:
<head>
{{-- ... --}}
@forgeSummary
</head>
指令输出一段 <script>,以一次性、消费即自删、不可枚举的 window 访问器暴露摘要:
<script>
Object.defineProperty(window, '__ROUTE_FORGE__', {
configurable: true,
enumerable: false,
get: function () {
var v = JSON.parse('…'); // 摘要 JSON,经 Js::from 做 script-safe 转义
delete window.__ROUTE_FORGE__;
return v;
}
});
</script>
契约与约束:
- 全局 key 固定为
__ROUTE_FORGE__(与前端消费实现对齐,勿单改)。 - 内嵌值 = 与
GET {endpoint_prefix}摘要端点响应逐字段一致的 JSON——由RouteRepository::getSummary()同一 producer 产出,复用其缓存(cache_driver/cache_ttl)、dev 旁路、包自身路由排除等全部既有语义;不新增 HTTP 端点,摘要端点与层级端点原样保留。 - 只嵌摘要,绝不内嵌层级路由表:各层级明细仍按
GET {endpoint_prefix}/{level}走 HTTP 懒加载(受保护层级的路由数据不得预置进公开 HTML,这是"懒加载 + 受保护"的产品定位)。 - XSS 安全编码:内嵌 JSON 经
Illuminate\Support\Js(内部JSON_HEX_TAG等)转义,</script>无法截断脚本块;禁止裸json_encode直拼。 - 不递增
schemeVersion:这是既有摘要契约的"投递方式"扩展,非协议变更。 - 无 Blade 场景不受影响:纯 SPA 独立部署 / Vite dev 不书写指令即不注入,前端自动回落网络摘要,行为不变。
- 语义:前端读一次 → 拿到摘要、访问器自删、
window上不再残留该全局;Object.keys(window)/JSON.stringify(window)扫不到。core 内部另有 module 级缓存兜住"同页多实例",后端无需处理多实例。
安全边界(如实说明):一次性自删只缩小数据在
window上的运行时驻留面;摘要数据仍随 HTML 源码可见,非抗 XSS / 抗网络窃取的硬边界。XSS 一旦可在消费前执行脚本即可读取该值。文档与实现均不得将其夸称为加密或安全机制。
3.2 Artisan命令
php artisan route:forge:list
查看所有路由的层级分配结果,用于开发调试和验证配置是否正确:
# 查看所有路由的层级分配
php artisan route:forge:list
# 仅查看指定层级
php artisan route:forge:list --level=admin
# JSON 格式输出(便于脚本处理)
php artisan route:forge:list --json
# 仅显示未分配层级的路由
php artisan route:forge:list --unassigned
# 仅显示别名条目(旧名 → 真实路由名,见 §3.1.7)
php artisan route:forge:list --aliases
输出示例:
| Name/Alias | Level | Methods | URI | Alias Of |
|---|---|---|---|---|
| auth.login | public | POST | auth/login | — |
| admin.users.index | admin | GET|HEAD | admin/members | admin.members.index |
| admin.members.index | admin | GET|HEAD | admin/members | — |
| client.orders.store | client | POST | client/orders | — |
| debug.info | unassigned | GET|HEAD | _debug/info | — |
表格说明:
Name/Alias列中,别名整行以黄色显示;真实路由行保持默认颜色,但被别名 依赖的真实路由名以绿色显示(改名时需同步更新别名映射的审计信号);被忽略的撞车声明 以红色行显示在表格末尾(该行不代表可用路由名,不进入 JSON routes)。颜色仅 table 模式 存在,--json输出保持纯文本。别名行与真实路由行的 URI 相同——别名在元信息中就是 一个真实存在的路由名(§3.1.7 契约),Alias Of只是给人看的辅助标记。
行为说明:
- 数据源:直接从 Laravel 路由注册表(
Route::getRoutes())读取, 不需要启动 HTTP 服务,离线可用。 - 层级分配逻辑与运行时完全一致(遵循 §3.1.4 五级优先级)。
--level过滤时,若层级名不存在则提示可用层级列表;unassigned特殊层级也可作为--level过滤值。--unassigned仅显示未命中任何层级的路由,与--level=unassigned等价。- 未分配路由在 table 输出中以
unassigned显示(与 JSON 输出及特殊层级名保持一致)。 - 表格上方输出层级统计汇总行(
Tier counts: ...,含 0 路由的层级与unassigned;计数在过滤前统计,别名跟随目标层级计入,与摘要route_count口径一致)。unassigned非零时追加 warn 提示检查 match 规则或补显式 tier。 - 路由解析抛出 Forge 异常(
RF_BE_001等)时输出[错误码] 消息并以退出码 1 结束,不打印堆栈。 - 「有 tier 无 name」的路由(§3.1.4)在 table 模式输出 warning 行、JSON 模式合入
warnings——该类路由无法进入任何元信息,命令层直接暴露避免静默消失。 - 警告(别名撞车 / tier 无 name)在任何过滤结果下都输出:过滤后 0 行早退时同样可见。
- 别名条目(§3.1.7)跟随目标路由的层级与过滤条件显示,
Alias Of列(JSON 中为alias_of字段)标注指向的真实路由名;别名撞车被忽略等非致命问题以 warnings 提示(table 模式在表格前输出警告行)。
--json 输出结构
--json 输出结构化对象(便于脚本消费),而非纯数组:
{
"levels": ["public", "client", "manage", "admin", "unassigned"],
"filter": null,
"count": 5,
"tier_counts": {"public": 1, "client": 1, "manage": 0, "admin": 2, "unassigned": 1},
"warnings": [],
"routes": [
{
"name": "auth.login",
"level": "public",
"methods": ["POST"],
"uri": "auth/login",
"alias_of": null
},
{
"name": "admin.users.index",
"level": "admin",
"methods": ["GET"],
"uri": "admin/members",
"alias_of": "admin.members.index"
},
{
"name": "debug.info",
"level": "unassigned",
"methods": ["GET"],
"uri": "_debug/info",
"alias_of": null
}
]
}
字段说明:
levels:当前可用层级列表(始终含unassigned特殊层级)。filter:当前过滤条件(--level/--unassigned/--aliases),无过滤时为null。count:匹配路由总数。tier_counts:各层级路由计数(过滤前统计;含 0 路由层级与unassigned;别名计入目标层级,与摘要route_count一致)。warnings:非致命问题(别名撞车被忽略、「有 tier 无 name」等),供 CI/脚本检测配置问题;无问题时为空数组。routes:路由条目数组,每条含name、level(未分配为"unassigned")、methods、uri、alias_of(真实路由名为null,别名为指向的目标路由名,见 §3.1.7)。
alias_of语义(从别名视角命名):本行name是一个别名时,alias_of即它所指向的真实路由名。 换言之:Name/Alias 列回答「这一行叫什么」,Alias Of 列回答「若它是别名,真身是谁」。 该字段仅存在于 list--json与管理器数据中,端点元信息(routes 对象)没有此字段—— 别名与真实路由在元信息层不可区分,是「前端零改动」契约的前提。
设计意图:开发阶段最常被问到的问题是"我的路由到底被分到了哪个层级"。这个命令让开发者无需启动前端、无需打开浏览器,一条命令即可验证配置效果。
php artisan route:forge:types
从 Laravel 路由注册表生成 TS 类型声明文件,为前端 forge.api() 调用提供编译期类型安全:
# 生成所有层级的路由类型(默认输出到 stdout,便于预览和管道处理)
php artisan route:forge:types
# 仅生成指定层级
php artisan route:forge:types --level=admin
# 写入文件(跨项目写入前端目录)
php artisan route:forge:types --out=../frontend/src/types/forge-routes.d.ts
# JSON 格式输出(便于脚本或工具链二次消费)
php artisan route:forge:types --json
行为说明:
ForgeLevel联合类型覆盖所有已配置层级(含 0 路由的空层级,映射中输出空对象块),前端引用空层级名不会 TS 报错;--level过滤时仅含该层级。--json输出中空层级序列化为{}(与「按路由名索引的对象」契约一致,不会出现[])。- d.ts 文件头「端点」注释取实际
endpoint_prefix(经与端点注册相同的规范化)。 - 「有 tier 无 name」的路由输出 warning 到 stderr(stdout 即 d.ts 产物本身,警告不混入重定向产物)。
- 路由解析抛出 Forge 异常(
RF_BE_001等)时输出[错误码] 消息并以退出码 1 结束,不打印堆栈。
生成结果示例(d.ts):
// AUTO-GENERATED by route:forge:types. Do not edit.
// 生成时间: 2026-08-22T12:00:00.000Z
// 端点: /_forge/routes
import { ForgeRouteMap } from "@route-forge/core";
// ─── 层级名联合类型 ───────────────────────────────────────────
export type ForgeLevel = 'public' | 'manage' | 'admin';
// ─── 各层级路由名联合类型 ─────────────────────────────────────
export type ForgeRouteName<L extends ForgeLevel> = L extends keyof ForgeRouteMap
? keyof ForgeRouteMap[L] & string
: never;
// ─── 单条路由元信息(端点返回的 routes 值结构) ────────────────────
export interface ForgeRouteMeta {
method: string;
uri: string;
parameters: string[];
parameter_defaults: Record<string, unknown>;
}
// ─── 按层级 → 路由名 → 类型约束的映射 ────────────────────────
// 通过 module augmentation 增强 @route-forge/core 的 ForgeRouteMap,
// 使 useForge / useForgeApi 自动获得路由名和参数的类型推断。
// ⚠ 依赖 @route-forge/core 包,请确保前端项目已安装该依赖。
declare module '@route-forge/core' {
interface ForgeRouteMap {
public: {
'auth.login': {
method: 'POST';
params: {};
body: unknown;
response: unknown;
};
};
admin: {
'admin.users.show': {
method: 'GET';
params: { user: string | number };
response: unknown;
};
'admin.users.store': {
method: 'POST';
params: {};
body: unknown;
response: unknown;
};
};
manage: {
'manage.users.store': {
method: 'POST';
params: {};
body: unknown;
response: unknown;
};
};
}
}
// ─── 本地别名(向后兼容) ──────────────────────────────────────
export type ForgeRoutes = ForgeRouteMap;
// ─── 工具类型:从 ForgeRouteMap 提取具体字段 ────────────────────
/** 提取指定层级 + 路由名的 method */
export type ForgeMethod<
L extends ForgeLevel,
N extends ForgeRouteName<L>,
> = L extends keyof ForgeRouteMap
? N extends keyof ForgeRouteMap[L] ? ForgeRouteMap[L][N]['method'] : never
: never;
/** 提取指定层级 + 路由名的路径参数 */
export type ForgeParams<
L extends ForgeLevel,
N extends ForgeRouteName<L>,
> = L extends keyof ForgeRouteMap
? N extends keyof ForgeRouteMap[L] ? ForgeRouteMap[L][N]['params'] : never
: never;
/** 提取指定层级 + 路由名的 body 类型(GET/DELETE 为 never) */
export type ForgeBody<
L extends ForgeLevel,
N extends ForgeRouteName<L>,
> = L extends keyof ForgeRouteMap
? N extends keyof ForgeRouteMap[L]
? 'body' extends keyof ForgeRouteMap[L][N]
? ForgeRouteMap[L][N]['body']
: never
: never
: never;
/** 提取指定层级 + 路由名的 response 类型 */
export type ForgeResponse<
L extends ForgeLevel,
N extends ForgeRouteName<L>,
> = L extends keyof ForgeRouteMap
? N extends keyof ForgeRouteMap[L] ? ForgeRouteMap[L][N]['response'] : never
: never;
结构说明:
- 映射通过
declare module '@route-forge/core'的 module augmentation 增强ForgeRouteMap接口——二级映射,第一级 key 是层级名(如public/admin/manage),第二级 key 是路由名 (如admin.users.show)。前端 composable(useForge / useForgeApi)自动获得类型推断;export type ForgeRoutes = ForgeRouteMap为本地别名(向后兼容)。 - 生成的 d.ts 依赖前端包
@route-forge/core(import 语句),前端项目需已安装。 - 每条路由包含:
method(字符串字面量,取第一个非 HEAD 的 HTTP 方法)、params(路径参数对象,类型固定string | number;URL 可选参数{param?}标记为?;无参数时为空对象{})、body(仅 POST/PUT/PATCH 方法有此字段,默认unknown)、response(响应类型,默认unknown)。 parameter_defaults独立返回参数默认值,与 TS 类型的?标记无关: URL 必选参数{user}即使设置了默认值,在 TS 中仍然是必选字段; 前端可根据parameter_defaults在构造 URL 时自动填充默认值。ForgeLevel、ForgeRouteName<L>用于在前端forge.api(level, name, params)调用时提供类型约束。- 工具类型
ForgeMethod、ForgeParams、ForgeBody、ForgeResponse用于从ForgeRouteMap中提取指定层级 + 路由名的具体字段类型。
行为说明:
- 数据源:直接从 Laravel 路由注册表(
Route::getRoutes())读取, 不需要启动 HTTP 服务,离线可用。 - 层级分配逻辑与运行时完全一致(遵循 §3.1.4 五级优先级)。
- 路径参数类型默认
string | number(Laravel 路由定义不声明参数类型)。 - body/response 类型默认
unknown,由业务侧通过单独的响应类型映射补上类型(避免侵入后端代码)。 --level过滤时,若层级名不存在则提示可用层级列表;仅生成匹配层级的路由,d.ts 中仅包含该层级分组。--json输出二级 JSON 结构(层级 → 路由名),与 d.ts 结构对应。- 未分配层级的路由(unassigned)不生成类型,因其无层级归属。
设计意图:路由定义是后端数据,类型必须由后端这个唯一真相源生成——后端改了路由,跑一次命令前端类型即同步,杜绝手写类型与路由表脱节。前端调用时需要同时传入层级名和路由名(
forge.api(level, name, params)),层级名是加载指定层级路由的前提。相比前端 CLI 请求端点生成,Artisan 命令离线可用,CI 中 PHP 构建阶段无需 Node 环境。
php artisan route:forge:clear
清除 Route Forge 路由元信息缓存,支持全量清除或按层级清除:
# 清除全部缓存(含摘要端点)
php artisan route:forge:clear
# 仅清除指定层级缓存
php artisan route:forge:clear --level=admin
行为说明:
- 全量清除时通过
RouteCache::clear()基于 keys 索引一次性清空所有route-forge:*缓存键(含摘要端点route-forge:summary)。 --level清除时失效指定层级的缓存键,并同步失效摘要端点缓存——摘要中该层级的route_count依赖路由数据,层级清理后统计会变化,缓存中的旧摘要不再可信。--level指定的层级名不存在时提示可用层级列表。- 开发模式(
APP_DEBUG=true)下缓存本就不写入,执行此命令无实际效果但不会报错。 - 联动清除:执行 Laravel 内置的
php artisan route:clear时,自动连带清除 Route Forge 缓存(通过监听CommandStarting事件实现)。
3.3 管理器页面(开发环境)
仅在 APP_DEBUG=true(开发环境)下可用的可视化路由管理面板,提供:
- 层级总览:卡片式展示各层级路由数量、描述、加载策略(eager/lazy),点击卡片快速过滤
- 路由列表:表格展示所有命名路由的层级、方法、URI、中间件,支持搜索与按层级/HTTP 方法过滤
- 层级详情:点击路由名弹出模态框,展示完整元信息(参数、默认值、中间件等)
- 配置编辑:表单编辑全局设置(端点前缀、缓存 TTL、严格模式等),JSON 编辑器编辑 levels 层级配置,保存后直接写入
config/forge.php
访问地址:
GET /_forge/manager # 管理器页面(HTML)
GET /_forge/manager/api/routes # 所有路由及层级分配(JSON)
GET /_forge/manager/api/config # 当前配置(JSON)
PUT /_forge/manager/api/config # 更新配置文件
行为说明:
- 仅开发环境可用:
APP_DEBUG=false时不注册任何管理器路由,零泄露风险。 - IP 白名单:开发环境内管理器路由统一经
ManagerAllowedIps中间件,按manager_allowed_ips配置限制来源 IP——默认['127.0.0.1', '::1']仅本机可访问 (浏览器访问 localhost 可能解析为 IPv6 的::1,故一并放行);列表元素'*'放行任意来源;null/ 空数组表示显式不做 IP 限制。局域网调试时把开发机局域网 IP 追加进列表即可。线上APP_DEBUG=false根本不注册管理器路由,本配置天然不生效、可无视。 - 数据源与 Artisan 命令一致,直接从 Laravel 路由注册表读取,层级分配逻辑与运行时完全一致(遵循 §3.1.4 五级优先级)。
- 配置保存会重新生成
config/forge.php文件;若存在编译缓存的配置(php artisan config:cache)则一并清除,使下一个请求重新读取配置文件生效。开发环境通常未缓存配置,下一个请求即时读取新文件。- 已配置
classifier回调时拒绝保存并返回 422(闭包无法序列化回配置文件,避免静默抹掉回调); - 不在表单中编辑的配置项(
endpoint_middleware、manager_allowed_ips、aliases)保存时原样透传,不会丢失; aliases映射在配置页以只读区块展示(别名 → 真实路由名),便于审计;编辑走config/forge.php或路由宏->forgeAlias();- ⚠ 保存会把整个
config/forge.php重写为静态值:通过env('FORGE_*')提供的动态覆盖(如FORGE_CACHE_TTL)会被展开为字面量,保存后不再响应环境变量变更;如需保留 env 覆盖能力,请手工编辑配置文件。
- 已配置
- 表格区域限高(
max-height: calc(100vh - 240px)),路由多时表格内部滚动,表头 sticky 吸顶。 - 前端零构建依赖:Blade 视图 + 原生 CSS + 原生 JavaScript,无需 Node.js 构建流程。
设计意图:开发阶段除了 Artisan 命令行,开发者还需要一个更直观的可视化界面来理解路由层级分配、调试配置匹配规则、快速编辑配置。管理器页面填补了这一需求,同时严格限制为开发环境专属,不影响生产安全。
5. 配置项参考
| 键 | 类型 | 默认值 | 说明 |
|---|---|---|---|
levels |
array<string, LevelConfig> |
见 3.1.2 | 层级定义表,键为层级名(自定义),值为该层级的匹配规则与缓存策略 |
levels.{name}.description |
string |
'' |
层级描述,仅用于文档与调试输出 |
levels.{name}.match.prefix |
string[] |
[] |
URI 前缀匹配列表,命中任一即归入此层级 |
levels.{name}.match.middleware |
string[] |
[] |
中间件匹配列表,匹配逻辑受 middleware_match 控制 |
levels.{name}.match.middleware_match |
string|array |
'any' |
中间件匹配模式:'any'(OR)/ 'all'(AND)/ DNF 数组(见 §3.1.2 中间件匹配模式) |
levels.{name}.load |
'eager'|'lazy' |
'lazy' |
是否在摘要端点中标记为「前端应预加载」;前端自动发现时据此决定预加载策略 |
levels.{name}.endpoint_middleware |
string[]|null |
[] |
访问该层级元信息端点(GET /{endpoint_prefix}/{level})时要求的中间件列表;未配置或空数组则不限制 |
endpoint_prefix |
string |
'/_forge/routes' |
路由元信息对外端点前缀(同时用于层级端点和摘要端点) |
url_prefix |
string|null |
null |
应用的路由前缀,通过摘要端点 config.url_prefix 下发。支持完整 URL(含协议域名)或仅路径前缀;null 或空字符串 = 不下发 |
endpoint_middleware |
string[] |
[] |
摘要端点(GET /{endpoint_prefix})中间件;空数组或 null 不限制 |
cache_ttl |
int|null |
3600 |
统一缓存 TTL(秒);null 不缓存,0 永久缓存,负值视为 null(不缓存)。同时作用于所有层级端点与摘要端点。⚠️ 0 遵循 Laravel Cache TTL 惯例(永久),非 HTTP Cache-Control: max-age=0 含义 |
cache_driver |
string|null |
null |
缓存驱动;null 用默认驱动,可指定 redis/file/array 等 |
strict_mode |
bool |
false |
严格模式;未命中层级时抛异常(true)或归入 unassigned 特殊层级(false) |
scheme_version |
int |
1 |
摘要端点返回的响应格式版本号(schemeVersion 字段);后续迭代引入不兼容的格式变更时递增,前端据此做版本兼容 |
classifier |
callable|null |
null |
自定义分类回调,签名 fn(Route $r): ?string,返回层级名或 null。返回的层级名必须在 levels 配置中存在,否则抛 UnknownClassifierTierException |
aliases |
array<string, string> |
[] |
路由别名映射表(见 §3.1.7):键=别名(旧路由名),值=真实路由名(新名)。与 ->forgeAlias() 宏并用时宏优先;悬空别名抛 AliasTargetException |
manager_allowed_ips |
string[]|null |
['127.0.0.1', '::1'] |
管理器页面 IP 白名单(仅 APP_DEBUG=true 有意义,线上不注册管理器路由可无视):精确匹配来源 IP,'*' 放行任意来源,null/空数组不做限制 |
6. 错误码
所有 Route Forge 抛出的异常都实现 ForgeExceptionContract 契约,提供 code()(错误码)与 httpStatus()(对应 HTTP 状态码)方法,便于调用方 catch 后统一处理与错误响应映射。
| 错误类 | code | 触发场景 | HTTP 状态 |
|---|---|---|---|
RouteTierNotAssignedException |
RF_BE_001 |
strict_mode=true 且路由未命中任何层级 |
500 |
UnknownLevelException |
RF_BE_002 |
请求的层级名不在 levels 配置中 |
404 |
CacheDriverException |
RF_BE_003 |
指定的 cache_driver 不可用 |
500 |
ClassifierException |
RF_BE_004 |
classifier 回调抛错 |
500 |
RouteMissingNameException |
RF_BE_005 |
strict_mode=true 且路由设置了 tier 但没有路由名 |
500 |
UnknownClassifierTierException |
RF_BE_006 |
classifier 返回的层级名不在 levels 配置中 |
500 |
DiscardedRegistrarAttributesException |
RF_BE_007 |
尾部链式属性被丢弃(Route::group(...)->tier(...)):自 v1.4.x 起不再自动抛出,改为日志告警(strict_mode=true 记 error、默认记 warning),异常类保留供兼容 |
— |
AliasTargetException |
RF_BE_008 |
别名指向的路由名不存在(悬空别名,见 §3.1.7) | 500 |
7. 测试矩阵
| 测试维度 | 覆盖点 |
|---|---|
| 层级分配 | 显式 ->tier()、资源路由(resource/apiResource/singleton/apiSingleton)tier 与 ->only() 组合、配置 match、Route::group(数组/链式)透传、classifier(含返回非串降级、抛错包装为 ClassifierException)、unassigned 兜底、优先级覆盖、多层级同时命中取最后一个 |
| 路由别名 | ->forgeAlias() 宏(单个/多个/空参报错)、config aliases、宏优先于 config、别名与真实路由名撞车忽略(list warnings)、悬空别名 RF_BE_008、别名元信息与目标纯复制一致、跟随目标层级(含 unassigned)、摘要 route_count 计入别名、route:forge:types 别名条目、route:forge:list --aliases 过滤、别名随扫描缓存 |
| Artisan 命令 | route:forge:list 输出格式(table/json)、按层级过滤、unassigned 路由显示、--level 参数过滤 |
| Artisan 命令 | route:forge:types 生成 d.ts 二级结构(层级 → 路由名)、--level 过滤、--json 二级 JSON 输出、--out 写文件 |
| Artisan 命令 | route:forge:clear 全量清除缓存、按层级清除、无效层级名报错 |
| 中间件匹配 | middleware_match 简单模式(any/all)、高级模式(DNF 数组)、边界情况(空数组、单元素、越界索引、空子句跳过、未知模式降级为 any);prefix 按段匹配(admin 不命中 administrator) |
| 端点响应 | /_forge/routes/{level} 返回结构、/_forge/routes 摘要端点返回结构、层级+摘要缓存命中、未声明层级 404、层级端点中间件保护、摘要端点中间件保护、自定义 endpoint_prefix 注册与规范化、parameter_defaults 空值序列化为 {}、空层级 routes 序列化为 {} |
| 严格模式 | strict_mode=true 未命中抛异常、false 未命中归入 unassigned 特殊层级、包自身路由豁免严格校验(全部用户路由已分配时端点 200) |
| Laravel 兼容 | CI(GitHub Actions)跑 PHP 8.2–8.5 × Laravel 11/12/13 矩阵(排除 PHP 8.2 × Laravel 13 组合);资源路由、嵌套 group、链式语法、Router 重绑共享 RouteCollection |
| 缓存 | array/file 驱动(store 无关,redis 复用同一 Repository 接口,无专属逻辑)、TTL 正数过期、手动失效、0 永久缓存、cache_ttl=null 不缓存、keys 索引清理、debug 模式跳过读写 |
| 管理器页面 | GET /_forge/manager 页面渲染、/api/routes(含 forge 自身路由过滤与 unassigned 归属)、/api/config 读取、配置生成器层级名转义(php -l 实测防注入)与不可编辑项(endpoint_middleware / manager_allowed_ips)保存透传、IP 白名单(默认仅回环、局域网 IP 精确匹配、* 放行、空值不限制);仅 APP_DEBUG=true 注册 |
8. 版本与发布
8.1 v1.0 能力清单(MVP)
- ✅ 层级分配(3 种方式):显式
->tier()宏(含资源路由 / singleton 链式)、Route::group透传、配置文件批量匹配 - ✅ 五级优先级:显式标注 > 分组继承 > 自定义回调 > 配置匹配 > 兜底/未分配
- ✅ 元信息端点:按层级返回路由元信息(名称 + URI + method + 参数)
- ✅ 摘要端点:返回所有层级概览与全局配置,供前端初始化自动发现
- ✅
middleware_match:支持any/all/ DNF 数组三种匹配模式 - ✅ 统一缓存:所有层级端点与摘要端点共享同一 TTL 配置,支持多种缓存驱动
- ✅ Artisan 命令:
route:forge:list查看层级分配结果、route:forge:types生成 TS 类型声明、route:forge:clear清除缓存 - ✅ 管理器页面:开发环境专属的可视化路由管理面板(层级总览、路由搜索/过滤、配置编辑)
8.2 v1.x 路线图
- ✅ v1.1:可视化路由管理面板(Blade + 原生 JS,开发环境专属)
路线图已清空,包转入维护态(兼容矩阵跟进 Laravel 新版本、缺陷修复、issue 支持)。
已评估并放弃的方向:
OpenAPI 桥接(从 OpenAPI spec 生成 body/response 类型):价值前提是项目已 持有 OpenAPI 文档(如经 Scramble 自动生成),未持有则为每个接口手写 schema 的 成本高于直接在前端手写 TS 类型,等于双重维护;且响应类型推断与包「路由元信息 单一真相源」的核心定位正交(SPEC §3.2 设计上 body/response 即为 unknown, 由业务侧自行补类型)。2026-08-30 评估后放弃。Vite 插件(dev 时自动 codegen,HMR 同步路由变更):属前端工具链,不在 后端包范围。
8.3 兼容性承诺
- Laravel:支持当前活跃维护的 3 个大版本(v1 发布时为 11/12/13);新版本发布 6 个月内适配。
- PHP:支持 8.2+。