很多人写了几年的 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

记住这个表,线上排查「为什么页面白了但日志没东西」的时候会省很多时间。