学完这篇,你能把 PHP 里「错误」和「异常」两条线收敛成一套可预测的处理流程,做到线上出问题有日志可查、有兜底页可看、不把 bug 藏在 catch 里。
第一步:先分清两类问题,别混着治
PHP 里会打断流程的东西其实有两种:
- 错误(Error):
E_WARNING、E_NOTICE、E_DEPRECATED 这类非致命的;以及 E_ERROR、E_PARSE、内存耗尽这类致命且无法被 catch 的。PHP 7 之后还有 Error 类(TypeError、DivisionByZeroError),它实现了 Throwable,可以 catch。
- 异常(Exception):代码主动
throw 出来的,代表「预料之中的分支」,比如参数校验失败、余额不足、第三方接口超时。
设计原则一句话:错误通常说明代码有 bug,异常通常说明业务流程走到了岔路。 所以别把所有错误都无脑转成异常再一律吞掉——那等于把 bug 埋进日志深处。
第二步:把可恢复的错误转成异常
E_WARNING 这类默认只是打条提示就继续跑,很难统一处理。用 set_error_handler 把它们抬成 ErrorException,让整条链路只面对 Throwable:
set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline): bool {
// 尊重 @ 抑制符和 error_reporting 设置
if (!(error_reporting() & $errno)) {
return false;
}
// 致命级别不要在这里处理,交给 shutdown 兜底
if (in_array($errno, [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
return false;
}
throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
});
注意:error_reporting() & $errno 这个判断不能省。少了它,加了 @ 的代码会突然抛异常,很多老库(尤其文件操作、fopen)会当场炸掉。
第三步:建自己的异常体系,分两层就够
不要每个模块造一个异常类,两层足够用:
class AppException extends RuntimeException {} // 业务异常:可预期
class ValidationException extends AppException {} // 参数不合法
class NotFoundException extends AppException {} // 资源不存在
class SystemException extends RuntimeException {} // 系统异常:数据库、第三方、IO
- 业务异常:上层捕获后转成用户提示(表单红字、404 页),不记 error 日志,记 info 就够。
- 系统异常:必须记录完整堆栈,给用户的永远是一句通用提示,别把 SQL 语句甩到页面上。
调用方按需 catch:catch (ValidationException $e) 精确处理,catch (SystemException $e) 记日志后重抛或降级。绝不要 catch (Exception $e) {} 空实现。
第四步:搭全局兜底三件套
再小心也有漏网的,入口文件里固定注册这三个:
// 1. 未捕获异常
set_exception_handler(function (Throwable $e) {
error_log((string) $e); // 带堆栈
http_response_code(500);
echo '服务开小差了,请稍后再试';
});
// 2. 致命错误(E_ERROR / 内存超限 / 解析错误)
register_shutdown_function(function () {
$err = error_get_last();
if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
error_log(sprintf('[FATAL] %s in %s:%d', $err['message'], $err['file'], $err['line']));
if (!headers_sent()) {
http_response_code(500);
}
}
});
// 3. 非致命错误转异常(第二步那段)
这三者分工明确:set_error_handler 管「能继续跑」的,set_exception_handler 管「抛出来没人接」的,register_shutdown_function 管「进程直接死了」的。缺一个就会留下盲区。
注意:set_error_handler 永远捕获不到 E_ERROR、E_PARSE 和内存耗尽,这是设计如此,只能靠 shutdown 函数 + error_get_last() 补救。
第五步:finally 释放资源,日志保留原始栈
数据库连接、文件句柄、临时文件、锁,一律放 finally:
$fp = fopen($tmp, 'w');
try {
fwrite($fp, $data);
} finally {
fclose($fp); // 无论成功失败都执行
}
包装异常时必须保留原始异常,否则丢了调用栈就查不动:
try {
$this->api->send($payload);
} catch (GuzzleException $e) {
throw new SystemException('短信通道调用失败', 0, $e); // 第三个参数是 previous
}
排查时用 $e->getPrevious() 一路追到底。
注意:生产环境 display_errors=Off 的同时必须 log_errors=On 且配好 error_log 路径。关显示是为了安全,关日志就是自断线索。
注意:别用异常控制正常流程。用 throw 跳出循环、做条件判断,会让性能和心理负担都变差。
小结
- 错误和异常是两条线:错误指向 bug,异常指向业务分支,别混为一谈。
- 用
set_error_handler 把非致命错误抬成 ErrorException,但要判断 error_reporting(),别破坏 @。
- 自定义异常分两层:业务异常转用户提示,系统异常记日志 + 通用提示。
- 兜底三件套缺一不可:
set_error_handler、set_exception_handler、register_shutdown_function。
- 资源释放走
finally,包装异常传 previous,日志一定要带堆栈。
- 生产环境关
display_errors,但 log_errors 必须开。