Laravel异常响应格式怎么统一

wen PHP项目 1

Laravel异常响应格式统一:从混乱到规范的实战指南

📚 目录导读

  1. 为什么需要统一异常响应格式?
  2. Laravel异常处理核心机制解析
  3. 通过异常处理器全局格式化(推荐)
  4. 使用中间件拦截并转换响应
  5. 基于自定义异常类+Traits实现复用
  6. 实战:统一JSON API的异常结构(含代码示例)
  7. 常见问题FAQ
  8. 总结与最佳实践

为什么需要统一异常响应格式?

在前后端分离或API驱动的项目中,异常响应的一致性直接决定了前端团队的处理效率,试想以下场景:

Laravel异常响应格式怎么统一

混乱场景(未统一):

// 用户不存在时
{ "error": "User not found" }
// 验证失败时
{ "message": "The email field is required", "errors": {...} }
// 服务器错误时
{ "exception": "Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException" }

统一后

{
  "code": 40400,
  "message": "用户资源未找到",
  "data": null,
  "trace_id": "abc123"
}

核心价值

  • 前端统一解析逻辑,无需为每种错误写适配器
  • 提升API的自文档化能力
  • 便于日志监控与问题追踪

Laravel异常处理核心机制解析

Laravel的异常处理流程基于 App\Exceptions\Handler 类,它继承自 Illuminate\Foundation\Exceptions\Handler,核心方法:

// app/Exceptions/Handler.php
public function register()
{
    $this->reportable(function (Throwable $e) {
        // 报告异常(如发送到Sentry)
    });
    $this->renderable(function (NotFoundHttpException $e, $request) {
        // 渲染自定义响应
        return response()->json([
            'code' => 40400,
            'message' => '资源不存在'
        ], 404);
    });
}

关键点

  • renderable() 方法可以对特定异常类型进行响应格式化
  • 若未定义,则走Laravel默认的render()方法返回HTML或JSON
  • 请求头包含Accept: application/json时自动输出JSON

方案一:通过异常处理器全局格式化(推荐)

适用场景:所有API响应需要统一字段结构。

实现步骤

Step 1:在Handlerregister()中设置通用响应格式

// app/Exceptions/Handler.php
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
public function register()
{
    $this->renderable(function (Throwable $e, $request) {
        if ($request->expectsJson() || $request->is('api/*')) {
            return $this->formatApiException($e);
        }
    });
}
protected function formatApiException(Throwable $e): JsonResponse
{
    // 默认状态码
    $statusCode = method_exists($e, 'getStatusCode') 
        ? $e->getStatusCode() 
        : 500;
    // 错误码映射(业务码)
    $errorCode = $this->getErrorCode($e);
    // 提取消息
    $message = $e->getMessage() ?: '服务器内部错误';
    // 若为验证异常,提取errors字段
    $data = null;
    if ($e instanceof ValidationException) {
        $data = $e->errors();
        $message = '参数验证失败';
        $statusCode = 422;
        $errorCode = 42200;
    }
    return response()->json([
        'code'    => $errorCode,
        'message' => $message,
        'data'    => $data,
        'trace_id' => request()->trace_id ?? uniqid(),
    ], $statusCode);
}
protected function getErrorCode(Throwable $e): int
{
    switch (true) {
        case $e instanceof NotFoundHttpException:
            return 40400;
        case $e instanceof ModelNotFoundException:
            return 40401;
        case $e instanceof ValidationException:
            return 42200;
        case $e instanceof AuthenticationException:
            return 40100;
        case $e instanceof AuthorizationException:
            return 40300;
        default:
            return 50000;
    }
}

Step 2:确保debug模式不会暴露敏感信息

# .env 生产环境必须关闭
APP_DEBUG=false

优势:零侵入业务代码,一次配置全局生效。


方案二:使用中间件拦截并转换响应

适用场景:现有项目过渡期,不能修改Handler但又想统一格式。

实现代码

// app/Http/Middleware/UnifyApiResponse.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class UnifyApiResponse
{
    public function handle($request, Closure $next)
    {
        $response = $next($request);
        if ($response instanceof JsonResponse || $response->headers->get('content-type') === 'application/json') {
            // 对异常响应进行重写
            if ($response->exception) {
                $e = $response->exception;
                $statusCode = $response->status();
                $newResponse = [
                    'code'    => $statusCode * 100,
                    'message' => $e->getMessage() ?: 'Unknown error',
                    'data'    => null,
                    'trace_id' => request()->trace_id,
                ];
                return response()->json($newResponse, $statusCode);
            }
        }
        return $response;
    }
}

注册中间件

// app/Http/Kernel.php
protected $routeMiddleware = [
    'unify.api' => \App\Http\Middleware\UnifyApiResponse::class,
];
// 在路由中应用
Route::middleware('unify.api')->group(function () {
    Route::apiResource('users', UserController::class);
});

缺点:可能造成重复处理(如果Handler已处理过一次)。


方案三:基于自定义异常类+Traits实现复用

适用场景:需要灵活控制不同模块的异常码,或者需要携带业务数据抛出异常。

自定义异常类

// app/Exceptions/AppException.php
namespace App\Exceptions;
use Exception;
class AppException extends Exception
{
    protected int $errorCode;
    protected mixed $errorData = null;
    public function __construct(
        string $message = '',
        int $errorCode = 50000,
        int $httpCode = 500,
        mixed $data = null
    ) {
        parent::__construct($message, $httpCode);
        $this->errorCode = $errorCode;
        $this->errorData = $data;
    }
    public function getErrorCode(): int
    {
        return $this->errorCode;
    }
    public function getErrorData(): mixed
    {
        return $this->errorData;
    }
}

响应格式化Trait

// app/Traits/ApiExceptionResponse.php
namespace App\Traits;
use App\Exceptions\AppException;
use Illuminate\Http\JsonResponse;
trait ApiExceptionResponse
{
    public function render($request, Throwable $e): JsonResponse
    {
        if ($e instanceof AppException) {
            return response()->json([
                'code'    => $e->getErrorCode(),
                'message' => $e->getMessage(),
                'data'    => $e->getErrorData(),
                'trace_id' => request()->trace_id,
            ], $e->getCode() ?: 500);
        }
        // 默认走Laravel原生处理
        return parent::render($request, $e);
    }
}

使用示例

// 在业务中抛出
throw new AppException(
    '订单已关闭', 
    errorCode: 40012, 
    httpCode: 400, 
    data: ['order_id' => 123]
);

实战:统一JSON API的异常结构(含完整代码)

最终响应格式定义

{
  "code": 42200,
  "message": "验证失败",
  "data": {
    "email": ["邮箱格式不正确"],
    "password": ["密码至少8位"]
  },
  "trace_id": "req_65f3d2e1a4b9c"
}

完整的Handler实现

// app/Exceptions/Handler.php
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Throwable;
class Handler extends ExceptionHandler
{
    protected $levels = [
        // 忽略报告级别(可选)
    ];
    public function register()
    {
        $this->reportable(function (Throwable $e) {
            // 生产环境可发送到日志服务
            if (app()->environment('production')) {
                \Log::error($e->getMessage(), [
                    'trace_id' => request()->trace_id,
                    'url' => request()->fullUrl(),
                ]);
            }
        });
    }
    public function render($request, Throwable $e): JsonResponse
    {
        if ($request->expectsJson() || $request->is('api/*')) {
            return $this->formatApiResponse($e);
        }
        return parent::render($request, $e);
    }
    private function formatApiResponse(Throwable $e): JsonResponse
    {
        $httpCode = $this->getHttpStatusCode($e);
        $errorCode = $this->getErrorCode($e, $httpCode);
        $message = $this->getErrorMessage($e);
        $data = $this->getErrorData($e);
        // 生产环境隐藏详细错误信息
        if (app()->environment('production') && $httpCode === 500) {
            $message = '服务器内部错误';
        }
        return response()->json([
            'code'     => $errorCode,
            'message'  => $message,
            'data'     => $data,
            'trace_id' => \Str::uuid()->toString(),
        ], $httpCode);
    }
    private function getHttpStatusCode(Throwable $e): int
    {
        if ($e instanceof NotFoundHttpException) return 404;
        if ($e instanceof MethodNotAllowedHttpException) return 405;
        if ($e instanceof ValidationException) return 422;
        if ($e instanceof AuthenticationException) return 401;
        if ($e instanceof AuthorizationException) return 403;
        // 检查是否自定义异常携带了code
        if ($e instanceof AppException && $e->getCode()) {
            return $e->getCode();
        }
        return 500;
    }
    private function getErrorCode(Throwable $e, int $httpCode): int
    {
        // 业务错误码:HTTP状态码+两位序列号
        if ($e instanceof AppException) {
            return $e->getErrorCode();
        }
        $codes = [
            NotFoundHttpException::class => 40400,
            MethodNotAllowedHttpException::class => 40500,
            ValidationException::class => 42200,
            AuthenticationException::class => 40100,
            AuthorizationException::class => 40300,
        ];
        foreach ($codes as $class => $code) {
            if ($e instanceof $class) {
                return $code;
            }
        }
        return $httpCode * 100; // 如 50000
    }
    private function getErrorMessage(Throwable $e): string
    {
        if ($e instanceof ValidationException) {
            return '参数验证失败';
        }
        return $e->getMessage() ?: '未知错误';
    }
    private function getErrorData(Throwable $e): mixed
    {
        if ($e instanceof ValidationException) {
            return $e->errors();
        }
        if ($e instanceof AppException) {
            return $e->getErrorData();
        }
        return null;
    }
}

常见问题FAQ

Q1:如何让前端知道错误码含义?

A:维护一个 错误码字典文档

{
  "40000": "通用请求参数错误",
  "40001": "参数缺失",
  "40100": "未授权,需要登录",
  "40300": "权限不足",
  "40400": "资源不存在",
  "42200": "验证失败",
  "50000": "服务器内部错误"
}

可在/api/docs/error-codes接口中动态返回。

Q2:统一格式后,debug调试如何看到原始异常?

A:在register()中判断app()->isLocal()

if (app()->isLocal()) {
    $this->renderable(function (Throwable $e, $request) {
        return response()->json([
            'debug' => [
                'class' => get_class($e),
                'file' => $e->getFile(),
                'line' => $e->getLine(),
                'trace' => $e->getTraceAsString(),
            ]
        ], 500);
    });
}

Q3:第三方包抛出的异常如何处理?

A:在Handler中覆盖$dontReport属性,并针对特定异常注册renderable

protected $dontReport = [
    \League\OAuth2\Server\Exception\OAuthServerException::class,
];

Q4:如何保证trace_id在日志和响应中一致?

A:在全局中间件生成并绑定到request

// app/Http/Middleware/AssignRequestId.php
public function handle($request, Closure $next)
{
    $request->trace_id = $request->header('X-Trace-Id') ?? \Str::uuid();
    return $next($request);
}

总结与最佳实践

推荐方案

  • 新项目:使用方案一(全局Handler) + 自定义AppException 组合
  • 已有项目:使用中间件方案快速过渡

核心原则

  1. 始终返回HTTP状态码+业务码
  2. 生产环境隐藏敏感信息(堆栈、文件路径)
  3. 为每个异常类分配唯一的业务码
  4. 使用trace_id关联日志与响应
  5. 保持data字段的维度一致性(空时返回null而非空数组)

扩展建议

  • 结合 Laravel API Resource 统一成功响应格式
  • 使用 TelescopeLaravel Log Viewer 监控异常
  • ValidationException增加字段级别的错误码

统一的异常响应不仅是技术规范,更是团队协作的契约,当你的前端团队不再需要写一系列if...else来处理不同的错误结构时,你的API设计才算真正专业。


参考文献:Laravel官方文档《Error Handling》、Laravel News社区最佳实践、Stack Overflow相关问答

抱歉,评论功能暂时关闭!