学完这篇你能自己写出一个 30 行的中间件管道,把 Laravel 的中间件机制看懂,并把它接进自己的 PHP 框架里处理登录、CSRF、限流这类横切逻辑。
第一步:先搞清楚中间件解决什么问题
假设你有 20 个控制器方法,其中 12 个需要登录校验。最笨的写法是每个方法开头 `if (!is_logged_in()) die();`,改一次规则要改 12 个地方。
中间件把这层逻辑从业务里抽出来,做成一条"洋葱管道":请求从外往里穿,每层决定放行还是拦截。
请求 → [限流] → [CSRF] → [登录] → 控制器 → [登录后置] → [日志] → 响应
关键词是"洋葱"——`$next($request)` 之前的代码在请求阶段执行,之后的代码在响应阶段执行。同一个中间件既能改请求,也能改响应。
第二步:看 Laravel 怎么写
Laravel 的中间件约定非常简单,只有一个 `handle` 方法:
class CheckLogin
{
public function handle($request, Closure $next)
{
if (!auth()->check()) {
return redirect('/login'); // 短路,后面的都不执行
}
$response = $next($request); // 放行
$response->headers->set('X-App', 'my'); // 后置处理
return $response;
}
}
注册位置分两代:
- Laravel 10 及以前:`app/Http/Kernel.php` 里的 `$middleware`(全局)、`$middlewareGroups`(如 `web` 组)、`$routeMiddleware`(路由别名)。
- Laravel 11 及以后:`Kernel.php` 已被删除,改为在 `bootstrap/app.php` 里用 `->withMiddleware(function (Middleware $middleware) { ... })` 注册。
注意:网上大量教程还在讲 `Kernel.php`,如果你用的是 Laravel 11+,照抄会找不到文件。别以为是装错了。
带参数的中间件用冒号传参,例如 `->middleware('role:editor')`,`handle` 方法签名变成 `handle($request, Closure $next, string $role)`。
第三步:自己写一个管道核心
原理其实只有一个函数:把中间件数组从后往前"包"成嵌套闭包。
class Pipeline
{
private $passable;
private array $pipes = [];
public function send($passable): static
{
$this->passable = $passable;
return $this;
}
public function through(array $pipes): static
{
$this->pipes = $pipes;
return $this;
}
public function then(Closure $destination)
{
// 从最内层开始包裹,最终得到第一层闭包
$pipeline = array_reduce(
array_reverse($this->pipes),
fn($next, $pipe) => fn($passable) => $pipe($passable, $next),
$destination
);
return $pipeline($this->passable);
}
}
中间件只要满足一个约定即可(用闭包或接口都行,接口更规范):
interface Middleware
{
public function handle($request, Closure $next);
}
这样 `$pipes` 数组里放的就是一个个可调用的中间件实例,顺序即执行顺序——数组第一个是最外层。
第四步:接进路由分发
在路由器匹配到控制器之后、调用方法之前插入管道:
$router->get('/post/{id}', [PostController::class, 'show'])
->middleware([Throttle::class, VerifyCsrf::class, Auth::class]);
分发时:
(new Pipeline)
->send($request)
->through($route->middleware)
->then(function ($request) use ($route) {
[$class, $method] = $route->handler;
return (new $class)->$method($request, ...$route->params);
});
到这里,一个能用的中间件体系就完成了,核心不到 30 行。
第五步:写两个最实用的中间件
CSRF 校验(顺便解释一个常见报错):
class VerifyCsrf implements Middleware
{
public function handle($request, Closure $next)
{
if (!in_array($request->method(), ['GET','HEAD'], true)
&& !hash_equals($_SESSION['csrf_token'] ?? '', $request->input('_token') ?? '')) {
http_response_code(419);
exit('页面已过期,请刷新后重试');
}
return $next($request);
}
}
注意:Clara BBS 里发帖时看到的"页面已过期,请刷新后重试"提示,本质就是这道校验没通过——通常是页面停留太久导致 token 过期,或站点地址填的是裸域而访问用了 www,导致会话丢失。
限流(简单版滑动窗口):
class Throttle implements Middleware
{
public function __construct(private int $max = 60, private int $seconds = 60) {}
public function handle($request, Closure $next)
{
$key = 'rl:' . $request->ip();
$count = (int) cache_get($key);
if ($count >= $this->max) {
http_response_code(429);
exit('操作太频繁,请稍后再试');
}
cache_set($key, $count + 1, $this->seconds);
return $next($request);
}
}
注意:中间件的注册顺序直接决定安全效果。限流应该在 CSRF 和登录校验之前(先挡掉洪水),日志类应该在最外层(记录到被拦截的请求);把 `Auth` 放在 `Throttle` 前面,未登录用户就会绕过限流。
中间件和钩子该用哪个
中间件的本质是围绕一次请求的管道,天然适合鉴权、限流、日志、响应头处理;而钩子(事件点观察者)适合"在某个动作发生时做点事",比如发帖成功后给作者加经验、给被 @ 的人发通知。Clara BBS 走的是后者:156 个运行时插件钩子,插件丢进 `content/plugins` 保存即生效,不需要编译或清缓存——因为它的扩展点全部落在业务事件上,而不是请求管道的每一层。选型看一点:**要拦截或改写请求/响应,用中间件;要在业务动作前后挂载副作用,用钩子。**
小结
- 中间件 = 洋葱管道,`handle($request, Closure $next)` 是唯一约定,`$next()` 之前是请求阶段,之后是响应阶段。
- 不调 `$next()` 或直接 `return` 响应即为短路,后续中间件不再执行。
- 核心实现就是 `array_reduce` + `array_reverse` 把数组包成嵌套闭包,30 行足够。
- Laravel 10 及以前看 `app/Http/Kernel.php`,11+ 看 `bootstrap/app.php`。
- 注册顺序 = 执行顺序,越靠前越外层;限流放最前,日志放最外。
- 鉴权/限流/CSRF 用中间件,业务副作用用钩子,别混用。