很多人写了几年的 try...catch,但遇到 Fatal error 抓不住、Warning 又不知道去哪了。这篇把 PHP 的错误与异常体系从根上讲一遍,附一套可以直接用的全局处理器。
一、先分清两套体系
PHP 里「出问题」有两种完全不同的机制:
错误(Error) —— 语言层面的问题。除零、类型不匹配、调用不存在的方法、语法错误。老版本里它们大多是 E_WARNING、E_NOTICE 这种「非异常」的通知。
异常(Exception) —— 代码主动抛出的控制流。throw new RuntimeException('...')。
PHP 7 之前这两套是割裂的:错误不能被 catch 捕获,只能靠 set_error_handler 接。
PHP 7 引入了 Throwable 接口,把两者统一了:
Throwable (interface)
├── Error (class) ← 引擎层面的错误,现在可以被 catch
│ ├── TypeError
│ ├── ValueError (PHP 8.0+)
│ ├── ArithmeticError
│ │ └── DivisionByZeroError
│ ├── ArgumentCountError
│ ├── ParseError
│ └── AssertionError
└── Exception (class) ← 业务代码抛出的
├── RuntimeException
├── LogicException
├── InvalidArgumentException
└── ...关键结论:catch (Throwable $e) 能捕获几乎所有问题,除了少数几种致命错误(E_ERROR、E_PARSE、E_CORE_ERROR、E_COMPILE_ERROR,比如内存耗尽、语法错误)。
try {
$result = intdiv(10, 0);
} catch (DivisionByZeroError $e) {
// PHP 7+ 可以这样抓了
echo '除数不能为 0';
}PHP 7 之前,intdiv(10, 0) 会直接 Warning 然后返回 false,抓不住。
二、set_error_handler:把警告变成异常
Warning、Notice、Deprecated 依然不是异常。想把它们纳入 try...catch,需要手动转换:
set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline): bool {
// 尊重 @ 抑制符和 error_reporting 设置
if (!(error_reporting() & $errno)) {
return false;
}
throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
});这一行 if (!(error_reporting() & $errno)) 很重要。PHP 8 里 @ 运算符会把 error_reporting() 临时设为一个很小的掩码,靠这个判断才能让 @ 继续生效。如果不加,@file_get_contents() 也会抛异常,反而把老代码搞崩。
副作用提醒:一旦装了转换器,所有 Warning 都变成异常。这意味着原来「警告一下继续跑」的代码会直接中断。建议只在开发环境开,生产环境用下面的全局处理器记录日志。
三、致命错误:只能靠 shutdown 兜底
E_ERROR 无法被 catch,也无法被 set_error_handler 捕获(因为脚本已经要终止了)。唯一的机会是 register_shutdown_function:
register_shutdown_function(function (): void {
$error = error_get_last();
if ($error === null) {
return;
}
$fatal = [E_ERROR, E_PARSE, E_CORE_ERROR, E_CORE_WARNING, E_COMPILE_ERROR, E_COMPILE_WARNING];
if (!in_array($error['type'], $fatal, true)) {
return;
}
// 记录日志
error_log(sprintf(
'[FATAL] %s in %s:%d',
$error['message'],
$error['file'],
$error['line']
));
// 给用户一个体面的页面,而不是白屏
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: text/html; charset=utf-8');
}
echo '服务暂时不可用
我们已经记录该问题,请稍后再试。
';
});注意 headers_sent() 判断——如果响应已经开始输出,再 header() 会再报一个 Warning。
四、全局异常处理器
未被 catch 的异常会走到 set_exception_handler:
set_exception_handler(function (Throwable $e): void {
error_log(sprintf(
"[UNCAUGHT] %s: %s in %s:%d\n%s",
get_class($e),
$e->getMessage(),
$e->getFile(),
$e->getLine(),
$e->getTraceAsString()
));
if (!headers_sent()) {
http_response_code(500);
}
// 生产环境不要输出堆栈
if (getenv('APP_ENV') === 'dev') {
echo '' . htmlspecialchars((string) $e) . '
';
} else {
echo '服务异常,请稍后再试';
}
});htmlspecialchars((string) $e) 这里必须转义——异常信息里可能含有用户输入,直接输出就是 XSS。
五、自定义异常层次
业务代码里到处 throw new Exception('用户不存在') 是最难维护的写法。按「可恢复性」分层:
// 基础异常,方便统一 catch
class AppException extends RuntimeException {}
// 参数问题,通常是客户端错误 → 400
class ValidationException extends AppException
{
public function __construct(private array $errors)
{
parent::__construct('参数校验失败');
}
public function errors(): array
{
return $this->errors;
}
}
// 资源不存在 → 404
class NotFoundException extends AppException {}
// 权限不足 → 403
class ForbiddenException extends AppException {}调用方就能按粒度处理:
try {
$user = $service->find($id);
} catch (NotFoundException $e) {
return response()->json(['msg' => '用户不存在'], 404);
} catch (AppException $e) {
return response()->json(['msg' => $e->getMessage()], 400);
}好处是:新增一种业务异常不用改上层 catch,因为它天然被 AppException 兜住。
六、异常链:别把现场弄丢
包装异常时一定要把原异常传进第三个参数:
try {
$this->http->post($url, $payload);
} catch (GuzzleException $e) {
// 好:保留原始异常,堆栈可以一路追下去
throw new RemoteServiceException('调用支付网关失败', 0, $e);
// 差:现场丢了,只剩一句「调用失败」
// throw new RemoteServiceException('调用支付网关失败');
}排查线上问题时,getPrevious() 链就是你的救命稻草。
七、finally 的三个用途
finally 无论是否抛异常都会执行,典型场景:
$lock = $this->acquireLock('order:' . $orderId);
try {
$this->process($orderId);
} finally {
// 1. 释放资源
$this->releaseLock($lock);
}try {
$pdo->beginTransaction();
$pdo->exec($sql1);
$pdo->exec($sql2);
$pdo->commit();
} catch (Throwable $e) {
// 2. 回滚。注意判断是否还在事务里
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}try {
return $this->doSomething();
} finally {
// 3. 清理临时文件、重置状态
@unlink($tmpFile);
}第三个例子有个细节:finally 里 return 会覆盖 try 里的返回值。除非有意为之,别在 finally 里 return。
八、几条实践原则
不要空 catch
// 最糟糕的写法:出了问题完全不知道
try {
$this->sync();
} catch (Exception $e) {
}至少要记录:
} catch (Exception $e) {
error_log('同步失败:' . $e->getMessage());
}不要在循环里 catch 后继续
// 一条失败不影响其他的场景才这样写,否则会掩盖系统性问题
foreach ($items as $item) {
try {
$this->handle($item);
} catch (Throwable $e) {
error_log("处理 {$item['id']} 失败:" . $e->getMessage());
}
}生产环境绝不显示堆栈
display_errors = Off
log_errors = On我在一些站点上见过页面底部直接打印 Warning: ... in /www/wwwroot/xxx/usr/plugins/xxx/Plugin.php on line 83——这既暴露了服务器绝对路径,也让用户觉得站点「坏了」。这个问题在部署检查清单里应该有一条。
九、完整的引导代码
放在入口文件最前面:
' . htmlspecialchars((string) $e) . ''
: '服务异常,请稍后再试';
});
register_shutdown_function(function (): void {
$error = error_get_last();
if ($error === null) {
return;
}
if (!in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
return;
}
error_log('[FATAL] ' . $error['message'] . ' in ' . $error['file'] . ':' . $error['line']);
if (!headers_sent()) {
http_response_code(500);
}
echo '服务暂时不可用';
});小结
| 问题类型 | 能被 catch 吗 | 兜底手段 |
|---|---|---|
Exception | ✅ | try...catch |
Error(TypeError 等) | ✅ | catch (Throwable) |
Warning / Notice | ❌(除非转换) | set_error_handler |
E_ERROR / E_PARSE | ❌ | register_shutdown_function |
记住这个表,线上排查「为什么页面白了但日志没东西」的时候会省很多时间。



0 条评论