前言
try/catch用起来简单,出事的时候却容易让人懵:明明写了 catch,异常还是冒到最外层;或者反过来,一个catch (\Exception $e)把本该暴露的TypeError吞掉了,程序带着坏数据继续往下跑,最后在一个完全不相关的地方报错。还有一类是 catch 块顺序写反,某个分支永远不执行,排查时看代码"明明写了"却毫无反应。
先纠正标题里的版号。多个catch块捕获不同异常不是 PHP 8.2 的特性,它从 PHP 5.0 引入异常机制时就有了;用竖线把多个异常类型写在一个 catch 里(联合捕获,multi-catch)是 PHP 7.1 引入的。PHP 8.2 在这件事上没有新增语法。所以本文按事实讲:语法自 5.0 / 7.1 起可用,在任何 8.x(包括 8.2、8.4)上都同样能跑,示例中的新语法会单独标注所需版本。
需要分清两个容易混淆的概念:
| 概念 | 写法 | 引入版本 |
|---|---|---|
| 多个 catch 块 | catch (A $e) { } catch (B $e) { } | PHP 5.0 |
| 联合捕获(multi-catch) | `catch (A\ | B $e) { }` |
Throwable接口 | catch (\Throwable $e) | PHP 7.0 |
finally块 | try { } catch { } finally { } | PHP 5.5 |
后文按"异常体系 → 捕获顺序 → 联合捕获 → 异常链与清理 → 实战"的顺序展开,最后一节列的是真会踩的坑。
一、先搞清楚 PHP 的异常体系
PHP 的错误与异常有两条根:
\Exception:传统意义上的异常。\Error:PHP 7.0 引入,表示 PHP 自身的错误,比如TypeError、ValueError(PHP 8.0)、DivisionByZeroError。- 两者的共同父接口就是
\Throwable(PHP 7.0)。
关键事实:\Error不继承\Exception。所以catch (\Exception $e)捕获不到TypeError。这是老代码升级到 8.x 后最常见的"catch 没生效"的原因。
Throwable (interface) ├── Exception │ ├── RuntimeException │ ├── InvalidArgumentException │ └── ... └── Error ├── TypeError ├── ValueError (PHP 8.0) ├── DivisionByZeroError └── ...写 catch 时先决定一件事:你是想"处理某几类业务异常,其余照常抛出",还是"兜底一切,保证接口一定返回 JSON"。前者 catch 具体的类,后者 catch\Throwable。
二、多个 catch 的匹配规则与顺序
PHP 从上到下依次匹配,第一个匹配上的 catch 执行,其余全部跳过。这里的"匹配"按继承关系走:子类异常也能被父类的 catch 接住,所以顺序必须"从具体到宽泛"。
<?php declare(strict_types=1); class ApiException extends RuntimeException {} class NotFoundException extends ApiException {} class ValidationException extends ApiException {} try { throw new NotFoundException('user 42 not found'); } catch (NotFoundException $e) { echo "404 分支\n"; } catch (ApiException $e) { echo "通用 API 分支\n"; } catch (Throwable $e) { echo "兜底分支\n"; }输出是404 分支。如果把catch (ApiException $e)提到最前面,NotFoundException就永远进不了第一个分支——这是静态代码检查能过、但行为完全错误的典型写法。
catch 后面可以跟变量名、类型、或者什么都不写(PHP 8.0 起允许省略变量):
} catch (NotFoundException) { // PHP 8.0+,不需要异常对象时可以省略变量名 echo "404\n"; }三、联合捕获:一个块处理多种异常
当几种异常的处理逻辑完全一样,用竖线合并:
<?php declare(strict_types=1); try { doSomething(); } catch (InvalidArgumentException | DomainException $e) { // $e 的静态类型是二者的最近公共祖先,通常是 RuntimeException 或 Exception echo get_class($e), ': ', $e->getMessage(), "\n"; }两个必须记住的约束:
- 联合里只能写类型,不能写变量,
catch (A | $e)是语法错误。 $e的静态类型变成联合的公共父类型,所以只能调用公共父类上有的方法。InvalidArgumentException::getMessage()没问题,但如果某个类型独有的方法就调不到了,只能instanceof判断或拆成两个 catch。
还有一条经验:不要把存在继承关系的两个类型写进同一个联合捕获,比如catch (ApiException | NotFoundException $e)。ApiException已经覆盖了它的子类,多写一个只是让人误以为两者会被区别对待,实际处理逻辑完全一样。要区别对待就拆成两个 catch,要合并就只写父类。
四、finally、异常链与自定义异常
finally无论是否抛异常都会执行,常用于释放资源。要小心它对返回值的影响——见坑点第 5 条。
异常链(exception chaining)用第三个参数传递上游异常,排查时能一路回溯到根因:
<?php declare(strict_types=1); class RepositoryException extends RuntimeException {} function findUser(PDO $pdo, int $id): array { try { $stmt = $pdo->prepare('SELECT id, name FROM users WHERE id = ?'); $stmt->execute([$id]); $row = $stmt->fetch(PDO::FETCH_ASSOC); } catch (PDOException $e) { // 把底层异常挂在链上,而不是只拼消息字符串 throw new RepositoryException("查询用户 {$id} 失败", 0, $e); } if ($row === false) { throw new NotFoundException("用户 {$id} 不存在"); } return $row; } try { findUser($pdo, 42); } catch (NotFoundException $e) { http_response_code(404); echo "not found\n"; } catch (RepositoryException $e) { http_response_code(500); error_log($e->getMessage() . "\n" . $e->getTraceAsString()); // 顺着链条打印根因 for ($prev = $e->getPrevious(); $prev !== null; $prev = $prev->getPrevious()) { error_log(' caused by: ' . get_class($prev) . ': ' . $prev->getMessage()); } echo "server error\n"; }getPrevious()返回链上的上一个异常,这条链是排查生产问题的关键。
实战:完整可运行示例
下面这个脚本把多 catch 顺序、联合捕获、\Error与\Exception的分野、异常链和finally放在一起,存成.php直接跑即可。它不依赖数据库。
<?php declare(strict_types=1); class ApiException extends RuntimeException {} class NotFoundException extends ApiException {} class ValidationException extends ApiException {} class RepositoryException extends ApiException {} /** * 模拟业务处理,返回处理结果字符串 */ function handle(string $mode): string { switch ($mode) { case 'notfound': throw new NotFoundException('资源不存在'); case 'invalid': throw new ValidationException('参数校验失败'); case 'repo': try { throw new PDOException('SQLSTATE[HY000] 连接超时'); } catch (PDOException $e) { throw new RepositoryException('仓储层不可用', 0, $e); } case 'type': // TypeError 属于 \Error,不是 \Exception return str_repeat([], 3); case 'divzero': return (string) (1 % 0); default: return 'ok'; } } foreach (['ok', 'notfound', 'invalid', 'repo', 'type', 'divzero'] as $mode) { echo str_pad($mode, 10), ' => '; try { echo handle($mode), "\n"; } catch (NotFoundException $e) { echo '404: ', $e->getMessage(), "\n"; } catch (RepositoryException | ValidationException $e) { // 联合捕获:两类走同一段处理 echo 'business[' . get_class($e) . ']: ', $e->getMessage(), "\n"; if ($e->getPrevious() !== null) { echo ' caused by ', get_class($e->getPrevious()), ': ', $e->getPrevious()->getMessage(), "\n"; } } catch (ApiException $e) { echo 'fallback api: ', $e->getMessage(), "\n"; } catch (DivisionByZeroError $e) { echo 'php error: 除零 ', get_class($e), "\n"; } catch (Throwable $e) { // 兜住 \Error(TypeError 等),避免整个脚本挂掉 echo 'unexpected: ', get_class($e), ': ', $e->getMessage(), "\n"; } finally { echo " [finally]\n"; } }输出:
ok => ok [finally] notfound => 404: 资源不存在 [finally] invalid => business[ValidationException]: 参数校验失败 [finally] repo => business[RepositoryException]: 仓储层不可用 caused by PDOException: SQLSTATE[HY000] 连接超时 [finally] type => unexpected: TypeError: str_repeat(): Argument #1 ($string) must be of type string, array given [finally] divzero => php error: 除零 DivisionByZeroError [finally]注意type那一行:如果最外层只写catch (ApiException $e)而没有catch (Throwable $e),这个TypeError会直接终止脚本,返回白页或者 500 且没有日志——这正是"catch 写了却不生效"的真实来源。
常见坑点
- ❌ 只 catch
\Exception:TypeError、ValueError、DivisionByZeroError都属于\Error,不继承\Exception,会直接冒到顶层。
✅兜底用catch (\Throwable $e);只想捕业务异常时,也别把Throwable完全排除在外。
- ❌ 父类 catch 写在子类前面:
catch (ApiException $e)放在catch (NotFoundException $e)之前,后者永远不执行,且代码能正常编译,审查时极难发现。
✅固定"从具体到宽泛"的顺序,把Throwable永远放最后。
- ❌ 在联合捕获里调某个类型的独有方法:
catch (A | B $e)后写$e->getDetail(),而getDetail()只有A有,运行时报Call to undefined method。
✅联合类型只写共有逻辑;需要区分时用instanceof分支,或者干脆拆成两个 catch。
- ❌ 空 catch 吞异常:
catch (Throwable $e) {},接口层面"永远不报错",实际上失败原因全部丢失,出问题时连一行日志都没有。
✅至少error_log()记一条,或者重新抛出一个带getPrevious()的领域异常。
- ❌ 忽略
finally里的return:finally中的return会覆盖try和catch里的return,静默改变函数结果。
✅finally只做清理(关连接、放锁、删临时文件),不要写return或break。
- ❌ 用
getMessage()拼字符串丢失栈信息:把$e->getMessage()塞进新异常的消息里,原始getTrace、getFile、getLine全没了。
✅用第三个参数传原异常:throw new MyException('...', 0, $e);,用getPrevious()取根因。
- ❌ 把异常信息直接输出给用户:
catch (\Throwable $e) { echo $e->getMessage(); },页面把文件路径、SQL 片段、内部结构全暴露出去,而日志里一条记录都没有,安全与运维两头都输。
✅对外只返回统一的错误码或提示语,把get_class($e)、getMessage()、getTraceAsString()写进error_log。
- ❌ 在 catch 里
throw新异常却不catch它:catch 块内部抛出的异常不会再被同一个 try 的其他 catch 块接住,而是继续向外冒。
✅记住 catch 块自身的异常走外层 try 的 catch,要么在外层再包一层,要么就地处理干净。
总结
| 需求 | 写法 | 最低版本 |
|---|---|---|
| 分别处理不同异常 | 多个catch块,具体在前 | PHP 5.0 |
| 多种异常走同一段逻辑 | `catch (A\ | B $e)` |
| 兜底一切并记录日志 | catch (\Throwable $e) | PHP 7.0 |
| 不需要异常对象 | catch (FooException) { } | PHP 8.0 |
| 保留根因 | throw new X('...', 0, $e)+getPrevious() | PHP 5.3 |
| 一定执行的清理 | finally { },不要写return | PHP 5.5 |
标题说的 PHP 8.2 并不是这些语法的引入版本——多 catch 是 5.0,联合捕获是 7.1,8.2 在这里没有新东西。真正要记住的只有两条:catch 顺序必须从具体到宽泛,兜底必须用\Throwable而不是\Exception。把这两条落到代码规范里,绝大多数"catch 不生效"的诡异现象就不会再出现。