最有效方式是在Handler的render()开头判断expectsJson()并统一返回自定义JSON响应,重写invalidJson()扁平化验证错误,自定义异常需实现接口并在render()中处理,日志须保留堆栈和请求详情。

API错误不统一,前端总要写一堆 if-else 判断 message 和 errors
直接改 App\Exceptions\Handler 是最有效的方式,但别动 render() 里默认的 response() 调用——它对 API 请求没做格式隔离。Laravel 默认会把 JSON 请求和普通请求混着处理,结果就是 422 返回的是 HTML 错误页,500 却又返回了 JSON,前端根本没法稳定解析。
实操建议:
- 在
app/Exceptions/Handler.php的render()方法开头加判断:if ($request->expectsJson()) { ... } - 所有异常都走自定义 JSON 响应,不要复用
parent::render()的返回值(它可能返回Response或JsonResponse,类型不一致) - 避免直接
return response()->json(...),而是封装一个apiErrorResponse()辅助方法,方便后续统一加 trace_id、时间戳等字段
ValidationException 的 errors 结构和你想要的不一致
Laravel 默认把验证失败的字段错误塞进 errors 数组,键是字段名,值是字符串数组,比如 {"email": ["The email must be a valid email."]}。但很多前端团队习惯扁平结构:{"message": "Validation failed", "details": [{"field": "email", "message": "The email must be a valid email."}]}。
实操建议:
- 重写
invalidJson()方法(Laravel 9+)或在render()中拦截ValidationException实例 - 用
$exception->errors()拿到原始数据,再用collect()+flatMap()扁平化,别用array_values()硬转——字段名丢了就难定位问题 - 注意
required_if这类条件规则可能让同一个字段出现多个错误,别用reset()只取第一个
自定义异常没走统一格式,比如手动抛的 throw new ApiException('xxx')
很多人写业务逻辑时随手 throw 新异常,但忘了注册它到 Handler 的 $dontReport 或 $dontFlash,更关键的是:没在 render() 里显式处理。结果就是 500 页面照常出现,或者返回空响应。
实操建议:
- 所有自定义异常必须继承
Exception,且推荐实现一个空接口如ApiExceptionContract,方便在render()里用instanceof快速识别 - 别在构造函数里硬编码 HTTP 状态码,把状态码、错误码(如
USER_NOT_FOUND: 1002)作为属性传入,保持异常类本身无框架依赖 - 如果用了包如
spatie/laravel-ignition,记得关掉它的 JSON 错误页面(IGNITION_ENABLED=false),否则会劫持你的格式
日志里看不到真实错误上下文,只有一行 "message": "Internal Server Error"
统一格式后容易犯一个隐蔽错误:为了“友好”,把所有 500 的 message 都改成 "Internal server error",结果日志里只剩这一句,查不到堆栈、参数、甚至请求路径。
实操建议:
- JSON 响应体里的
message可以简化,但日志必须保留$exception->getTraceAsString()和$request->fullUrl() - 用
Log::error()记录时,传数组而不是字符串:Log::error('API exception', ['exception' => $exception, 'request' => $request->all()]),否则 Monolog 会丢掉对象细节 - 开发环境可加
APP_DEBUG=true并在响应里返回trace字段,但上线前务必删掉——别让vendor/路径暴露在生产响应中
真正难的不是格式统一,是得想清楚哪些信息该给前端、哪些只能进日志、哪些要按环境开关。一不留神就把调试信息吐到响应里,或者把用户敏感数据记进了日志。


















