容器解决了「对象从哪来」,本节解决「请求过几道闸」。中间件是生命周期动线上唯一贯穿进出的环节:鉴权、限流、跨域、日志、语言检测这类「与业务无关但每个请求都要过」的处理,全部落在这里。读完本节,你应当能写出自己的中间件,并说清前置、后置与终止请求三种形态的执行顺序。
中间件管道的最佳图像是洋葱:请求从外层切进去,穿过每一层到达中心的控制器,响应再从中心原路切出来。每层中间件都有两次出场机会——进来的前置逻辑与出去的后置逻辑,中间夹着对下一层的调用。
// 一个中间件的完整骨架 namespace app\middleware; use Closure; use think\Request; use think\Response; class RequestLog { public function handle(Request $request, Closure $next): Response { // ---- 前置逻辑:请求进站时执行 ---- $start = microtime(true); $response = $next($request); // 把请求交给洋葱的下一层 // ---- 后置逻辑:响应出站时执行 ---- $cost = round((microtime(true) - $start) * 1000); $response->header('X-Debug-Cost', (string) $cost); return $response; } }
$next($request) 之前与之后的分界,就是前置与后置的分界。如果在调用 $next 之前直接 return 一个响应,请求连控制器都见不到——这就是「终止」形态,鉴权失败、限流触发都这么写。

中间件写好后要登记才生效,三个登记位对应三种作用范围:
// 1) 全局中间件:config/middleware.php,所有请求必经 return [ \think\middleware\LoadLangPack::class, \app\middleware\RequestLog::class, ]; // 2) 路由级中间件:随路由定义,只作用于该组路由 Route::group('admin', function () { Route::get('dashboard', 'Admin/dashboard'); })->middleware(\app\middleware\AdminAuth::class); // 3) 控制器级中间件:控制器内声明 class Order extends BaseController { protected $middleware = [\app\middleware\LoginAuth::class]; }
登记顺序决定执行顺序:全局中间件按配置数组顺序从外到内排列,因此越靠前的配置越先见到请求、最后送走响应。把开销小、可能终止请求的中间件放外层(比如限流),让被拒请求尽早短路,是性价比明确的排布。
中间件可以接收参数,典型如「某些接口要求登录之外还要求会员等级」:
class RequireLevel { public function handle(Request $request, Closure $next, string $level) { $user = $request->middleware_user ?? null; // 前置中间件挂上的当前用户 if (! $user || $user->level < (int) $level) { // 终止形态:不调用 next,直接回响应 return json(['code' => 401, 'msg' => '权限不足'], 401); } return $next($request); } } // 路由上传参:level 值传给 handle 的第三个参数 Route::post('buy', 'Order/create')->middleware(\app\middleware\RequireLevel::class, ':3');
鉴权链的标准分工由此成形:LoginAuth 负责认人并把用户对象挂到请求上,RequireLevel 只做等级判断——单一职责让每个中间件都简单到不必读源码就能信任。
背景:练习项目的下单接口要挡住手抖与脚本。操作:写 RateLimit 中间件,用缓存计数器(缓存用法第 6 章,先用文件驱动即可)以「用户 ID 加接口名」为键累计窗口内请求数,超阈值走终止形态返回 429;登记到下单路由上。结果:压测工具连发超限请求时收到 429,正常用户无感。解读:注意计数键里为什么必须带接口名——不带的话限流会殃及无辜接口;再想一层,这里用「用户 ID」做键,未登录接口该换成 IP,两套键的取舍就是限流中间件的核心设计点。变式:把阈值改成配置驱动,并给响应头加上剩余次数提示,做成对客户端友好的限流。
⚠️ 常见坑:后置逻辑不是必然执行——前置里抛出的异常会跳过本层后置直奔异常处理。所以释放资源、写完整日志这类「必须发生」的动作,别只依赖后置形态,关键场景要配合异常机制兜底。
💡 关键直觉:判断一件事该不该做成中间件,就问它「是否与具体业务无关、且需要横切多个入口」。答案是就上管道,不是就留在控制器或服务层。
中间件排错集中在三个疑点。一疑顺序:登录判断排在会话中间件之前,读到的永远是空会话——排查法是把全局与路由中间件的登记顺序打印出来对照动线,请求没进预期分支时先看这一步。二疑范围:本想只在接口组生效的中间件登记在了全局,页面请求被 JSON 响应打断——范围与登记方式(配置文件、应用还是路由)的对应关系要能脱口而出。三疑提前返回:中间件里返回了响应却忘记不调用下一步,请求看似「被拒」实则悬在半空——诊断动作是在洋葱入口处打一行日志,看请求到底走没走进下一层。
三个疑点共享同一个排查入口:把中间件当作可以在任意位置停靠的观察点,沿着动线每层留一行日志,一次请求跑完,整条管道的执行轨迹就摊在日志里——这比盯着代码默想要快得多。
$next 直接返回响应,鉴权与限流的拒绝路径全靠它。至此总线主干全部拆完:动线、装配、闸口都在手上了。下一章进入分拣段——路由如何把 URL 精确投递到控制器。