返回

解决 Laravel 11 404 页面 Web 中间件失效问题 (附方案)

php

解决 Laravel 11 中 404 页面不经过 Web 中间件的问题

搞 Laravel 11 开发,特别是用了 Twill CMS 之后,你可能碰到过这么个怪事儿:自己写的一个重定向中间件,对能正常访问的页面(比如 /existing-page,返回 200 OK)工作得好好的,能把用户跳转到你指定的任何地方。可一旦访问一个不存在的地址(比如 /non-existent-page,返回 404 Not Found),嘿,这中间件就跟消失了一样,根本不执行,重定向自然也就没戏了。

就算试了把中间件加到 bootstrap/app.php 里的 web 中间件组,或者在 web.php 里定义了 Route::fallback() 并给它套上中间件,结果还是一样——404 的请求就是不理睬你的中间件。

这到底是咋回事呢?

一、 问题根源:请求生命周期和异常处理

要弄明白为啥 404 请求不走 web 中间件组里的自定义中间件,得先看看 Laravel 处理请求的大致流程。

  1. 请求来了 : 请求先进来,通过 public/index.php
  2. 内核处理 : 请求被交给 HTTP 内核 (App\Http\Kernel)。
  3. 全局中间件 : 请求会先穿过 全局 HTTP 中间件。这些是在 $middleware 属性里定义的。
  4. 路由匹配 : Laravel 的路由器开始工作,尝试把请求的 URI 跟 routes/web.phproutes/api.php 里定义的路由规则配对。
  5. 找到路由 : 如果找到了匹配的路由:
    • 请求接着会穿过该路由所属的 中间件组 (比如 webapi 组)。
    • 然后,再穿过 路由特定 的中间件(就是用 ->middleware() 直接链式附加到路由定义上的那些)。
    • 最后,请求到达控制器方法执行业务逻辑。
  6. 找不到路由 (404) : 如果路由器翻遍了所有路由规则,都没找到能匹配当前请求 URI 的,它就没辙了。这时候,Laravel 会抛出一个 Symfony\Component\HttpKernel\Exception\NotFoundHttpException 异常。
  7. 异常处理 : 这个异常会被 Laravel 的异常处理器 (App\Exceptions\Handler) 捕获。默认情况下,异常处理器会查找对应的错误视图(比如 resources/views/errors/404.blade.php)并渲染返回给用户一个 404 页面。

关键点来了 :当 NotFoundHttpException 异常被抛出时,请求的常规处理流程就被 打断 了。它不会继续往下走,去执行原本计划要加载的中间件组(比如 web 组里我们加的那个 Redirector 中间件)或者路由特定的中间件。请求直接被拐到了异常处理那边去。

这就是为啥你的重定向中间件放在 web 组里对 404 请求无效。它根本没机会运行。Route::fallback() 稍微有点不同,它确实是在找不到其他路由时才会执行,但它本质上还是一个路由,其执行时机和环境,以及附加的中间件是否能在异常流程中稳定触发,可能不如我们预期的那么直接。

二、 解决方案

明白了原因,解决起来就有方向了。我们需要让重定向逻辑在 Laravel 判定这是一个 404 请求 之前 或者 在其处理过程中 介入。

方案一:把中间件提升为全局中间件

最直接的办法,就是不把 Redirector 中间件放在 web 组里,而是把它提升为全局中间件。全局中间件对 所有 HTTP 请求都生效,包括那些最终会变成 404 的请求,因为它们在路由匹配发生 之前 就运行了。

操作步骤:

  1. 打开 bootstrap/app.php 文件。
  2. 找到配置中间件的地方,通常是 ->withMiddleware(function (Middleware $middleware) { ... }) 这块。
  3. 不要$middleware->group('web', [...]) 里面加。
  4. 使用 $middleware->append(...)$middleware->prepend(...) 将你的中间件添加到全局栈。append 加到最后,prepend 加到最前面。对于重定向这种可能提前结束请求的逻辑,放在前面 (prepend) 可能更合适,避免不必要的后续处理。
// bootstrap/app.php

use App\Http\Middleware\Redirector; // 引入你的中间件
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware) {
        // 把你的重定向中间件加到全局,这里用 prepend 加到最前面
        $middleware->prepend(Redirector::class);

        // 这是原来 web 组的配置,里面不要再加 Redirector::class 了
        $middleware->group('web', [
            \Illuminate\Cookie\Middleware\EncryptCookies::class,
            \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
            \Illuminate\Session\Middleware\StartSession::class,
            \Illuminate\View\Middleware\ShareErrorsFromSession::class,
            \Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
            // ... 其他 web 中间件
            // \App\Http\Middleware\Redirector::class, // 这一行要从 web 组移除或注释掉
        ]);

        // 其他中间件配置...
    })
    ->withExceptions(function (Exceptions $exceptions) {
        // ...
    })->create();

原理与作用:

  • 全局中间件在 Laravel 处理流程的早期运行,早于路由查找。
  • 不管请求最终是 200 还是 404,你的 Redirector 中间件都会执行。
  • 在中间件内部,你可以检查请求的路径,查询数据库里的重定向规则。如果找到了匹配的规则,直接返回一个 RedirectResponse,请求就此结束,根本不会走到抛出 404 异常那一步。如果没找到匹配的重定向规则,就调用 $next($request) 让请求继续往下走。

代码示例 (Redirector 中间件大致逻辑):

// app/Http/Middleware/Redirector.php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redirect;
use App\Models\Redirection; // 假设你的重定向模型叫这个

class Redirector
{
    public function handle(Request $request, Closure $next)
    {
        // 获取当前请求路径,可能需要处理一下开头的 '/'
        $path = '/' . trim($request->path(), '/');

        // 去数据库或其他地方查找是否有针对这个路径的重定向规则
        // 注意:这里的查询逻辑需要根据你的 Redirection 模型/表结构调整
        $redirection = Redirection::where('source_url', $path)->where('is_active', true)->first();

        if ($redirection) {
            // 找到了!执行重定向
            // 使用永久重定向 (301) 还是临时重定向 (302) 取决于你的业务需求
            return Redirect::to($redirection->target_url, $redirection->status_code ?? 301);
        }

        // 没找到重定向规则,让请求继续正常处理
        return $next($request);
    }
}

安全建议:

  • 确保 Redirector 中间件里的数据库查询是高效的,因为它会对每个请求都执行。考虑使用缓存来减少数据库压力。
  • 对用户输入的重定向规则(如果允许用户配置的话)做好验证和清理,防止开放重定向漏洞 (Open Redirect Vulnerability)。

进阶使用技巧:

  • 你可以在中间件里加入更复杂的逻辑,比如基于用户角色、设备类型或其他请求特征来决定是否重定向。
  • 如果重定向规则很多,考虑优化查询性能,比如给 source_url 字段加索引。

方案二:自定义异常处理

另一个思路是让 Laravel 按照默认流程走,当它因为找不到路由而准备抛出 NotFoundHttpException 时,我们在异常处理器里把它“截胡”,然后执行我们的重定向逻辑。

操作步骤:

  1. 打开 app/Exceptions/Handler.php 文件。
  2. 找到 register() 方法。这个方法用来注册异常处理的回调。
  3. register() 方法内部,使用 $this->renderable() 来添加一个针对 NotFoundHttpException 的处理逻辑。
// app/Exceptions/Handler.php

namespace App\Exceptions;

use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redirect; // 引入 Redirect门面
use App\Models\Redirection; // 引入你的重定向模型

class Handler extends ExceptionHandler
{
    // ... 其他属性和方法 ...

    /**
     * Register the exception handling callbacks for the application.
     */
    public function register(): void
    {
        $this->renderable(function (NotFoundHttpException $e, Request $request) {
            // 检查是否是 web 请求 (如果你的重定向只针对 web)
            // 或者根据其他条件判断是否需要执行重定向逻辑
            if ($request->isMethod('GET')) { // 通常重定向只处理 GET 请求
                // 获取请求路径
                $path = '/' . trim($request->path(), '/');

                // 查找重定向规则 (同上一个方案的逻辑)
                $redirection = Redirection::where('source_url', $path)->where('is_active', true)->first();

                if ($redirection) {
                    // 找到规则,返回重定向响应
                    return Redirect::to($redirection->target_url, $redirection->status_code ?? 301);
                }
            }

            // 如果没有找到重定向规则,或者不满足执行重定向的条件,
            // 不返回任何东西 (返回 null),让 Laravel 继续执行默认的 404 页面渲染
            // 或者,如果你想完全自定义 404 页面,可以在这里返回一个自定义的视图响应
            // return response()->view('errors.custom_404', [], 404);
        });

        // 可以链式调用 reportable() 或 renderable() 注册其他异常处理
        // $this->reportable(function (Throwable $e) {
        //     // ...
        // });
    }

    // ... 其他方法,比如 render 方法可能已经存在,可以保留 ...
}

原理与作用:

  • 当 Laravel 捕获到 NotFoundHttpException 时,它会检查 Handler 类中是否有专门处理这种异常的 renderable 回调。
  • 我们注册的回调函数被触发。在这个回调里,我们再次执行查找重定向规则的逻辑。
  • 如果找到了规则,回调函数直接返回一个 RedirectResponse。这个响应会替代默认的 404 页面响应。
  • 如果没找到规则,回调函数不返回任何东西(或者说返回 null),Laravel 会继续执行它原本为 NotFoundHttpException 准备的默认处理流程(通常是渲染 404 错误视图)。

代码示例 (已包含在操作步骤中)

安全建议:

  • 同样要注意开放重定向漏洞的风险。
  • 在异常处理器里执行数据库查询要小心性能影响,虽然比全局中间件触发频率低(只在 404 时触发),但依然可能在遭受恶意扫描时被频繁调用。缓存策略同样适用。
  • 避免在错误处理逻辑中暴露敏感信息。

进阶使用技巧:

  • 你可以把重定向逻辑封装到一个单独的服务类里,然后在中间件和异常处理器里都注入并调用这个服务,避免代码重复。
  • 可以结合请求的其他信息(如 Referer 头)来实现更复杂的 404 处理策略。

关于 Route::fallback() 的一点补充

为啥之前尝试 Route::fallback()->middleware('redirect') 可能不行?虽然 fallback 路由本身会在找不到其他路由时执行,但它执行的时机以及附加在其上的中间件的执行,依然是在 Laravel 已经初步判断“找不到路由”之后。某些版本的 Laravel 或者特定的中间件交互下,这个中间件可能仍然无法按预期在最终渲染 404 响应之前可靠地触发并中断流程。全局中间件(方案一)或者异常处理(方案二)介入的时机更早或更专门针对“异常状态”,因此对于“在 404 发生时执行特定逻辑(如重定向)”这个需求通常更可靠。

选择哪个方案取决于你的具体需求和偏好。如果你的重定向逻辑很简单,或者你希望它能覆盖所有请求(包括未来可能添加的非 web 请求),全局中间件可能更省事。如果你想让重定向逻辑只在确认为 404 时才执行,并且希望保持中间件组织的“语义纯净性”(即 web 组处理常规 Web 逻辑,错误处理归于 Handler),那么自定义异常处理是更好的选择。