异常处理

简介

新建 Laravel 项目时,错误和异常处理已经帮你配好了。你随时可以通过 bootstrap/app.php 中的 withExceptions() 方法来管理应用的异常报告和渲染方式。

传给 withExceptions() 闭包的 $exceptions 对象是 Illuminate\Foundation\Configuration\Exceptions 的实例,负责管理应用中的异常处理。我们会在下文逐步介绍这个对象的用法。

配置

config/app.php 配置文件中的 debug 选项决定了错误信息展示给用户的详细程度。默认情况下,这个选项会读取 .env 文件中的 APP_DEBUG 环境变量。

本地开发时,应该把 APP_DEBUG 设为 true。

注意

生产环境中,APP_DEBUG 必须始终为 false。如果在生产环境设为 true,会把敏感的配置值暴露给终端用户。

处理异常

报告异常

在 Laravel 中,异常报告用来记录异常日志或发送到外部服务,比如 Sentry 或 Flare。默认情况下,异常会按照你的日志配置来记录。你也可以按自己的方式记录异常。

如果需要针对不同类型的异常采用不同的报告方式,可以在 bootstrap/app.php 中用 report() 方法注册一个闭包,在特定类型的异常需要报告时执行。Laravel 会通过闭包的类型提示来判断它处理的是哪种异常:

use App\Exceptions\InvalidOrderException;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->report(function (InvalidOrderException $e) {
        // ...
    });
})

用 report() 方法注册自定义异常报告回调后,Laravel 仍会按照默认日志配置记录该异常。如果你想阻止异常继续传递到默认日志栈,可以在定义报告回调时调用 stop() 方法,或直接在回调中返回 false:

use App\Exceptions\InvalidOrderException;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->report(function (InvalidOrderException $e) {
        // ...
    })->stop();

    $exceptions->report(function (InvalidOrderException $e) {
        return false;
    });
})

信息

要自定义特定异常的报告行为,也可以用可报告异常。

全局日志上下文

Laravel 会自动把当前用户的 ID 作为上下文数据添加到每条异常日志中。你可以在 bootstrap/app.php 中用 context() 方法定义自己的全局上下文数据,这些信息会包含在应用写入的每条异常日志里:

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->context(fn () => [
        'foo' => 'bar',
    ]);
})

异常日志上下文

虽然给每条日志添加全局上下文很有用,但有时候某个特定异常可能有独特的上下文需要记录。在异常类上定义一个 context() 方法,就可以指定与该异常相关的数据,这些数据会被添加到异常的日志条目中:

<?php

namespace App\Exceptions;

use Exception;

class InvalidOrderException extends Exception
{
    // ...

    /**
     * 获取异常的上下文信息。
     *
     * @return array<string, mixed>
     */
    public function context(): array
    {
        return ['order_id' => $this->orderId];
    }
}

report() 辅助函数

有时候你可能需要报告一个异常但继续处理当前请求。report() 辅助函数可以让你快速报告异常,而不会向用户渲染错误页面:

public function isValid(string $value): bool
{
    try {
        // 验证值...
    } catch (Throwable $e) {
        report($e);

        return false;
    }
}

异常报告去重

如果在应用中频繁使用 report() 函数,可能会多次报告同一个异常,导致日志中出现重复条目。

如果想确保同一个异常实例只报告一次,可以在 bootstrap/app.php 中调用 dontReportDuplicates() 方法:

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->dontReportDuplicates();
})

这样,当 report() 辅助函数收到同一个异常实例时,只有第一次调用会被报告:

$original = new RuntimeException('Whoops!');

report($original); // 已报告

try {
    throw $original;
} catch (Throwable $caught) {
    report($caught); // 忽略
}

report($original); // 忽略
report($caught); // 忽略

异常日志级别

消息写入应用日志时,会以指定的日志级别写入,表示被记录消息的严重程度或重要性。

如上所述,即使用 report() 方法注册了自定义异常报告回调,Laravel 仍会按默认日志配置记录异常。但由于日志级别有时会影响消息记录到哪些通道,你可能需要为某些异常配置特定的日志级别。

在 bootstrap/app.php 中用 level() 方法即可实现。该方法第一个参数是异常类型,第二个参数是日志级别:

use PDOException;
use Psr\Log\LogLevel;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->level(PDOException::class, LogLevel::CRITICAL);
})

按类型忽略异常

构建应用时,有些异常你永远不想报告。在 bootstrap/app.php 中用 dontReport() 方法可以忽略这些异常。传给该方法的任何类都不会被报告,但仍可能有自定义的渲染逻辑:

use App\Exceptions\InvalidOrderException;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->dontReport([
        InvalidOrderException::class,
    ]);
})

也可以直接在异常类上标记 Illuminate\Contracts\Debug\ShouldntReport 接口。标记了该接口的异常永远不会被 Laravel 的异常处理器报告:

<?php

namespace App\Exceptions;

use Exception;
use Illuminate\Contracts\Debug\ShouldntReport;

class PodcastProcessingException extends Exception implements ShouldntReport
{
    //
}

如果需要更精细地控制何时忽略某种异常,可以给 dontReportWhen() 方法传一个闭包:

use App\Exceptions\InvalidOrderException;
use Throwable;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->dontReportWhen(function (Throwable $e) {
        return $e instanceof PodcastProcessingException &&
               $e->reason() === 'Subscription expired';
    });
})

Laravel 内部已经帮你忽略了一些类型的错误,比如 404 HTTP 错误、来源不匹配导致的 403 响应、以及无效 CSRF 令牌导致的 419 响应。如果想让 Laravel 不再忽略某种异常,可以在 bootstrap/app.php 中用 stopIgnoring() 方法:

use Symfony\Component\HttpKernel\Exception\HttpException;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->stopIgnoring(HttpException::class);
})

渲染异常

默认情况下,Laravel 的异常处理器会把异常转换为 HTTP 响应。你可以为特定类型的异常注册自定义渲染闭包。在 bootstrap/app.php 中用 render() 方法即可实现。

传给 render() 方法的闭包应返回一个 Illuminate\Http\Response 实例,可以通过 response() 辅助函数生成。Laravel 会通过闭包的类型提示来判断它渲染的是哪种异常:

use App\Exceptions\InvalidOrderException;
use Illuminate\Http\Request;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->render(function (InvalidOrderException $e, Request $request) {
        return response()->view('errors.invalid-order', status: 500);
    });
})

render() 方法也可以用来覆盖 Laravel 或 Symfony 内置异常(如 NotFoundHttpException)的渲染行为。如果传给 render() 方法的闭包没有返回值,Laravel 会使用默认的异常渲染:

use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->render(function (NotFoundHttpException $e, Request $request) {
        if ($request->is('api/*')) {
            return response()->json([
                'message' => 'Record not found.'
            ], 404);
        }
    });
})

将异常渲染为 JSON

渲染异常时,Laravel 会根据请求的 Accept 头自动判断应该渲染成 HTML 还是 JSON 响应。如果你想自定义 Laravel 判断渲染 HTML 还是 JSON 的逻辑,可以用 shouldRenderJsonWhen() 方法:

use Illuminate\Http\Request;
use Throwable;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
        if ($request->is('admin/*')) {
            return true;
        }

        return $request->expectsJson();
    });
})

自定义异常响应

少数情况下,你可能需要自定义 Laravel 异常处理器返回的整个 HTTP 响应。用 respond() 方法可以注册一个响应自定义闭包:

use Symfony\Component\HttpFoundation\Response;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->respond(function (Response $response) {
        if ($response->getStatusCode() === 419) {
            return back()->with([
                'message' => 'The page expired, please try again.',
            ]);
        }

        return $response;
    });
})

可报告和可渲染的异常

除了在 bootstrap/app.php 中定义自定义报告和渲染行为,你还可以直接在异常类上定义 report() 和 render() 方法。当这些方法存在时,框架会自动调用它们:

<?php

namespace App\Exceptions;

use Exception;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

class InvalidOrderException extends Exception
{
    /**
     * 报告异常。
     */
    public function report(): void
    {
        // ...
    }

    /**
     * 将异常渲染为 HTTP 响应。
     */
    public function render(Request $request): Response
    {
        return response(/* ... */);
    }
}

如果你的异常继承自一个已经有渲染逻辑的异常(比如 Laravel 或 Symfony 的内置异常),可以在 render() 方法中返回 false 来使用异常的默认 HTTP 响应:

/**
 * 将异常渲染为 HTTP 响应。
 */
public function render(Request $request): Response|bool
{
    if (/** 判断是否需要自定义渲染 */) {

        return response(/* ... */);
    }

    return false;
}

如果你的异常包含只在特定条件下才需要的报告逻辑,可以在 report() 方法中返回 false,让 Laravel 使用默认的异常处理配置来报告:

/**
 * 报告异常。
 */
public function report(): bool
{
    if (/** 判断是否需要自定义报告 */) {

        // ...

        return true;
    }

    return false;
}

信息

report() 方法可以类型提示任何需要的依赖,Laravel 的服务容器会自动注入。

限制异常报告频率

如果应用报告了大量异常,你可能需要限制实际记录或发送到外部错误跟踪服务的异常数量。

在 bootstrap/app.php 中用 throttle() 方法可以按随机采样率限制异常报告。throttle() 方法接收一个闭包,闭包应返回一个 Lottery 实例:

use Illuminate\Support\Lottery;
use Throwable;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->throttle(function (Throwable $e) {
        return Lottery::odds(1, 1000);
    });
})

也可以根据异常类型有条件地采样。如果只想对特定异常类进行采样,可以只为该类返回 Lottery 实例:

use App\Exceptions\ApiMonitoringException;
use Illuminate\Support\Lottery;
use Throwable;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->throttle(function (Throwable $e) {
        if ($e instanceof ApiMonitoringException) {
            return Lottery::odds(1, 1000);
        }
    });
})

除了随机采样,还可以返回 Limit 实例来对异常进行速率限制。当你想防止异常突然激增淹没日志时(比如应用依赖的第三方服务宕机了),这很有用:

use Illuminate\Broadcasting\BroadcastException;
use Illuminate\Cache\RateLimiting\Limit;
use Throwable;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->throttle(function (Throwable $e) {
        if ($e instanceof BroadcastException) {
            return Limit::perMinute(300);
        }
    });
})

默认情况下,限制会以异常的类名作为速率限制键。你可以用 Limit 的 by() 方法自定义限制键:

use Illuminate\Broadcasting\BroadcastException;
use Illuminate\Cache\RateLimiting\Limit;
use Throwable;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->throttle(function (Throwable $e) {
        if ($e instanceof BroadcastException) {
            return Limit::perMinute(300)->by($e->getMessage());
        }
    });
})

当然,不同异常可以混合使用 Lottery 和 Limit:

use App\Exceptions\ApiMonitoringException;
use Illuminate\Broadcasting\BroadcastException;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Lottery;
use Throwable;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->throttle(function (Throwable $e) {
        return match (true) {
            $e instanceof BroadcastException => Limit::perMinute(300),
            $e instanceof ApiMonitoringException => Lottery::odds(1, 1000),
            default => Limit::none(),
        };
    });
})

HTTP 异常

有些异常代表服务器的 HTTP 错误码。比如"页面不存在"(404)、"未授权"(401),甚至开发者主动触发的 500 错误。要在应用的任何地方生成这样的响应,可以用 abort() 辅助函数:

abort(404);

自定义 HTTP 错误页面

Laravel 可以轻松为各种 HTTP 状态码显示自定义错误页面。比如要自定义 404 错误页面,只需创建一个 resources/views/errors/404.blade.php 视图模板。应用产生的所有 404 错误都会渲染这个视图。该目录下的视图文件名应与对应的 HTTP 状态码一致。abort() 函数抛出的 Symfony\Component\HttpKernel\Exception\HttpException 实例会作为 $exception 变量传给视图:

<h2>{{ $exception->getMessage() }}</h2>

可以用 vendor:publish Artisan 命令发布 Laravel 的默认错误页面模板,然后按自己的需要修改:

php artisan vendor:publish --tag=laravel-errors

兜底 HTTP 错误页面

还可以为一系列 HTTP 状态码定义"兜底"错误页面。当没有与特定状态码对应的具体页面时,就会渲染兜底页面。在 resources/views/errors 目录下定义 4xx.blade.php 和 5xx.blade.php 模板即可。

定义兜底错误页面时,兜底页面不会影响 404、500 和 503 错误响应,因为 Laravel 为这些状态码内置了专用页面。要自定义这些状态码的页面,应分别单独定义。