
1. 理解错误与异常这条分界线画错了后面会很难受PHP 的错误机制在过去十年里发生了很大的变化但说实话很多靠 PHP 吃饭的人对错误和异常的理解还停留在 PHP 5 时代甚至更早。每次我 review 别人的代码看到file_get_contents()后面跟一堆if判断或者吐槽PHP 的异常机制真难用的时候我都想拉着他们聊聊这个核心分界线错误Error和异常Exception在 PHP 里是两个不一样的东西而且从 PHP 7 开始这条分界线的处理方式又变了一次。在 PHP 5 里程序出问题主要是靠错误这个机制来通知的。比如E_WARNING、E_NOTICE它们不是一个可以被捕获的对象而是一个由 PHP 引擎直接抛出的信号传统做法是配合set_error_handler()去拦截。这种机制导致了一个很麻烦的局面每个函数都有可能静默地出问题然后在下一行代码里你才发现变量是 null 了但要回头查到底是哪个函数、在哪个环节吞掉了错误往往要废很大的功夫。PHP 7 引入了一个转折点把原来很大一部分致命错误E_ERROR改成了Error/Throwable体系让它们可以被try...catch捕获。这意味着 PHP 在向 Java、Python 看齐把异常作为处理程序故障的主要通道。但问题也来了一套全新的异常类体系和一个老旧的错误机制并存如果开发者不理解两者的分界极容易写出半异常半错误的代码表面看起来能用实际脆弱得不行。作为一个做了不少年 PHP 后端的人我的观点很直接在 PHP 7 之后的时代任何你自己的业务代码都应该把异常处理作为主路径把传统错误处理作为兜底。自定义错误类型不是为了炫技而是为了让你在几百个类、几十层调用栈里一眼看出问题出在哪一层、属于哪一类、该怎么处理。2. 系统级错误不够用的真正原因错误信息不等于错误处理很多人觉得错误处理就是把错误信息打出来这其实是个天大的误解。系统内置的异常和错误类型确实能在出状况时抛出一堆信息但它解决的是引擎视角的问题而不是业务视角的问题。举个例子你写了一个从外部 API 拉取用户数据的封装类。如果 API 超时了PHP 可能会抛一个ErrorException也可能是curl扩展返回 false 然后你在代码里手动判断还可能直接在file_get_contents上触发一个 WARNING。系统只会告诉你连接失败或者操作超时但不会告诉你这究竟是网络问题、对方服务 500、还是你这边代理配错了。等你的代码跑到了线上日志里躺着一堆含义不明的错误消息你靠什么来快速定位这就是自定义错误类型的第一层价值用异常类的名字表达错误的语义而不是让错误消息去承担这个语义。我见过一个比较典型的项目日志里全是PHP Fatal error: Uncaught Exception: Error: something went wrong.仔细一看原来是把所有的处理逻辑放在一个大的try...catch里不管什么错误都throw new \Exception(something happened)。后来排查生产事故只能靠猜。真正的解决方案很简单定义几个对应业务场景的异常类比如ApiConnectionException、ApiTimeoutException、ApiResponseParseException在抛出的瞬间就把问题类型固定下来catch 的时候按类别处理日志里按类名过滤。这个做法的好处是你从异常类名就能判断问题类别错误消息只是补充信息而不是唯一线索。再往深一层说自定义异常类也不仅仅是给异常起个好名字。它还会影响你的代码架构。在异常类型定义清晰的系统里业务代码和基础设施错误是可以分层的。比如你写的是 Service 层底层传来的是 PDOException你既不应该把 PDOException 直接抛给 Controller太底层调用者看不懂也不应该完全吞掉会丢失故障线索而是应该捕获它包装成一个自定义的RepositoryException再抛出去。这个包装动作把底层实现细节和上层业务语义隔离开来整个系统的可维护性会有非常大的提升。3. 一套可复用的 PHP 异常体系设计从场景出发不要从 API 出发自定义异常类说起来容易可是很多人一开始就会栽在类要怎么拆这个问题上。我建议你反过来想不要先想一共有几种异常类型而是先想你的系统里有哪些必须被区分对待的失败场景。换句话说异常体系是对业务失败场景的一次建模跟建表结构差不多这是需要认真设计的。3.1 一个具体场景报表导出功能里的错误处理我拿一个典型的例子说你有一个报表导出功能大致流程是先从数据库查数据然后生成 CSV 文件最后把文件通过邮件发出去。这个流程里可能会出现这些故障数据库查询因 SQL 语法错误或连接问题失败业务校验发现自己没有权限导出某些字段CSV 写入目录没有写权限邮件发送服务超时或返回失败。如果用系统内置的Exception来抛最后在 Controller 层你会收到很多Exception你只能靠 message 解析这是灾难。但如果自定义几个异常类型情况就会完全不一样。我给的方案是这样的?php namespace App\Exception; class ExportException extends \RuntimeException { protected array $context []; public function __construct(string $message, array $context [], int $code 0, ?\Throwable $previous null) { $this-context $context; parent::__construct($message, $code, $previous); } public function getContext(): array { return $this-context; } }首先定义一个基类ExportException它继承了RuntimeException。为什么用RuntimeException而不是Exception因为理论上导出相关的错误都属于运行时才可能暴露的错误——在代码编译阶段你是发现不了报表导不出、邮件发不出的它们不具备可检查性。选择RuntimeException作为基类能让人从类名直接感知到这个错误的发生时机这是一种类型层面的自描述。context属性是我非常强调的一个点。系统内置异常只有 message、code、file、line 这些信息但真实项目里往往还缺一部分关键上下文数据比如是哪个用户触发的、传入了哪些参数、操作的是哪个报表 ID。把这些塞进Exception的 message 里会导致日志乱成一团不塞进去排查问题时又要抓瞎。所以我把 context 作为构造参数传入异常并且提供getContext()方法拿取。然后是这个导出场景下的具体异常分工?php namespace App\Exception; class QueryDataException extends ExportException {} class PermissionDeniedException extends ExportException {} class CsvFileWriteException extends ExportException {} class EmailSendException extends ExportException {}这四个异常全部继承自ExportException。这样做的最大好处是调用方可以根据需要做两道拦截。?php try { $exportService-export($params); } catch (PermissionDeniedException $e) { // 返回 403给用户一个明确提示 } catch (ExportException $e) { // 其他导出相关异常统一记日志返回 500 }第一道拦截处理最特殊的权限问题第二道拦截兜底其他所有导出异常。因为所有自定义异常都继承自ExportException所以在 catch 时完全不用关心它们内部是不是来自数据库、文件系统还是邮件服务只需要关心业务类型。3.2 为什么我不建议直接 new Exception 再拼 message之前有一个项目原来的开发习惯是到处throw new \Exception(用户不存在)或者throw new \Exception(数据库连接失败)。到了后期需要根据不同类型的错误给用户返回不同的 HTTP 状态码或者做不同的告警处理结果就是每个 catch 块里都要做一堆字符串匹配?php catch (\Exception $e) { if (str_contains($e-getMessage(), 用户不存在)) { // 返回 404 } elseif (str_contains($e-getMessage(), 数据库)) { // 返回 500 } }这种写法的致命弱点是判断逻辑强依赖错误消息的文本内容。只要有人改了消息措辞哪怕只是加个空格你的分支逻辑就断了。更重要的是消息是给人看的本来就是可以随意改的而你却把它当成了程序的逻辑分支依据这本质上跟把用户提交的内容拼接进 SQL 没什么区别只是反过来的方向——都是不该作为代码逻辑依据却被硬用上了。自定义异常类型之所以是正解就在于它把这条错误到底是什么从这条错误用什么文字描述中彻底剥离了出来。程序逻辑依赖的是异常类型人读的才是消息文本。基于类型的 catch 和路由是稳定的基于消息的字符串匹配是脆弱的。3.3 三个内置的 SPL 异常不要忽视讨论自定义异常的时候有一个前提容易被带偏好像什么场景都得自己 new 一个异常类。这个说法对业务异常成立但 PHP 自带了一批 SPL 异常很多时候直接拿过来用完全够而且表达力更好。InvalidArgumentException参数不合法、超出范围、格式不对。这个我几乎每写一个公共方法都会用到。DomainException当前操作不适用当前领域状态。比如订单状态是已发货你要执行取消订单操作这就不符合业务领域规则。LogicException代码层面的逻辑错误理论上不应该发生。比如 switch 分支走到了 default说明可能代码有 bug。RuntimeException运行时才能发现的错误比如连接超时、文件不存在等。我见过一些人把所有数据库查询失败都抛成RuntimeException你这个 SQL 写错了、表不存在了这在开发阶段就该暴露抛RuntimeException可以用但它表达力不够。SQL 构造出错更适合用LogicException或者\PDOException原样抛出因为这是程序员的 bug不是外部环境的不稳定因素。用好这些 SPL 异常能让你的代码在你不在场时依然具有极强的自解释能力。4. 代码落地自定义异常类的完整实操细节聊完理念和设计原则下面看具体写代码时需要注意的细节。这些点看起来小却能直接决定你的异常体系是否真的在团队里落地、是否好用。4.1 写一个带上下文数据的通用基础异常在实际项目中我建议先写一个通用的、基于RuntimeException的应用异常基类作为整个项目的根异常。这个基类不但能统一提供上下文数据和日志友好性还能给后面所有业务异常提供一致的接口。?php namespace App\Exception; class ApplicationException extends \RuntimeException { protected array $context []; protected string $errorCode APPLICATION_ERROR; public function __construct( string $message , array $context [], int $code 0, ?\Throwable $previous null ) { $this-context $context; $this-errorCode $errorCode ?? $this-errorCode; parent::__construct($message, $code, $previous); } public function getContext(): array { return $this-context; } public function getErrorCode(): string { return $this-errorCode; } public function __toString(): string { $details json_encode($this-context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); return sprintf( [%s] %s (context: %s), $this-errorCode, $this-getMessage(), $details ?: [] ); } }这个基类有几个设计细节值得推敲。第一errorCode字段。真实系统里后面对接 API 网关、前端国际化消息或者根据错误码查文档都是很常见的场景。光有异常类名还不够再配一个稳定的字符串错误码相当于给项目里的非技术人员比如前端、客服也留了一个通用的沟通语言。比如USER_NOT_FOUND、ORDER_STATUS_INVALID这类错误码其实不属于任何具体技术框架本来是信息架构层面的设计很适合放在异常体系里统一管理。第二__toString()方法的覆写。这个方法在日志输出时会被自动调用习惯上用(string) $e或者 echo 一个异常对象时触发。我在这里让日志输出带上错误码和上下文可以极大降低日志排查的难度。不过有一个细节要克制__toString()里不要包含getTraceAsString()的信息因为异常 trace 可能非常长混入错误码和 context 之后日志会被刷得很乱而且很多日志框架本来就会记录 trace不需要你自己重复输出。第三构造函数对$previous的透传。这个其实很重要它保证了链式异常不会断。一个常见的反面例子底层抛了一个 PDOException你在 Service 层把它接住后写throw new ApplicationException(保存失败: . $e-getMessage());然后不传$previous。这样做的坏处是原始的 PDOException 对象被丢弃了它的 trace、它更深层的上下文完全丢失排查时你只能看到保存失败SQLSTATE[HY000]但看不到完整的 SQL、看不到是哪个调用链触发的。正确写法是throw new ApplicationException(保存失败, [table users], 0, $e);把 previous 挂上核心故障链路就不再断裂。4.2 如何区分业务异常和基础设施异常很多团队的自定义异常体系做不好是因为他们把业务错误和基础设施错误混在一个基类下面。我自己比较习惯的做法是分成两条分支ServiceException服务层异常及其子类描述业务规则被违反的场景。例如InsufficientBalanceException、OrderCannotCancelException。InfrastructureException基础设施异常及其子类描述与外部系统交互失败的场景。例如RedisConnectException、HttpRequestTimeoutException。为什么一定要拆开因为这两类异常的处理策略完全不同。业务异常多数是要返回给用户看的比如余额不足无权限而基础设施异常只能内部记录不能直接对外暴露细节否则会泄露内部网络架构、数据库结构等信息。而且这两者的监控告警级别也不一样业务异常属于正常业务流的一部分出现频率高但不用紧张基础设施异常则往往代表系统健康状态出了问题需要立刻响应。?php namespace App\Exception; class ServiceException extends ApplicationException {} class InfrastructureException extends ApplicationException {}然后在各层代码里只抛出符合该层语义的异常。比如在 Repository 层不抛OrderCannotCancelException你抛InfrastructureException的一个子类在 Service 层不直接抛 PDOException而是捕获并包装为对应的基础设施异常。这种异常分层的设计效果是在大型项目里最明显的。有一次线上查询超时我扫一眼 Sentry 里的异常类名和分组立刻知道是数据库层面的问题而不用去翻几百条错误消息。这是企业级 PHP 系统里异常类型即监控视图的体现。4.3 捕获与二次包装的最佳实践接下来是一个很容易做错的关键环节捕获异常后什么时候应该直接抛、什么时候应该包装后抛我的判断标准很简单如果原始异常的类型和语义对外层调用者来说仍然有意义的就不要包装如果外层调用者需要的是更高层次的业务语义就必须包装。举一个实际例子。你在一个 Service 方法里调用了 HTTP 客户端?php public function fetchUserProfile(int $userId): array { try { $response $this-httpClient-get(/users/{$userId}); } catch (ConnectException $e) { throw new ExternalServiceException( 用户服务暂时不可用, [user_id $userId], 0, $e ); } ... }这里我要求必须把ConnectException包装成ExternalServiceException。因为 Controller 层或者调用方不需要知道底层用的是 Guzzle、curl 还是其他什么客户端它们只关心用户服务挂了。如果不包装底层库的异常类型就泄漏到了上层一旦换掉 HTTP 客户端所有 catch 这个底层异常的地方都要改非常脆弱。与之相对如果你在 Config 读取阶段发现一个配置项缺失MissingConfigException本身就是语义清晰的异常类型不应该再包一层什么RuntimeException直接让它往上冒泡即可。归根到底包装的目的不是制造更多 catch 块而是让每一层代码只依赖它真正关心的抽象。5. 还有几个很多人没认真对待的异常处理细节如果上面这些你都理解了下面这几个细节可以说是老手和新手的区别所在。每一个都是我实际踩过坑之后才总结出来的。5.1 catch 顺序的问题先小后大前面举了一个 catch 两块异常的例子但实际项目里异常继承关系可能更深catch 的顺序如果不注意就会出现子类异常永远捕获不到的坑。?php try { // do something } catch (\Exception $e) { // 所有异常都走这里 } catch (ExportException $e) { // 永远不可能执行到这里 }PHP 是按顺序匹配 catch 块的一旦第一个 catch 能匹配上某个异常后续的 catch 就不会再检查。所以规则是子类异常在前父类异常在后最宽泛的兜底异常放最后。这个顺序错了代码不报错但行为完全是错的。5.2 finally 块里不要再抛异常这是我见过很多团队都踩过的一个坑。有些人喜欢在 finally 里做资源清理然后在清理过程中抛异常。比如?php try { // 业务逻辑 } finally { $file-close(); // 如果 close 失败抛了异常 }问题在于如果 try 块里已经抛了一个异常此时 finally 里又抛了一个新异常PHP 会用新异常覆盖掉原来的异常原始异常和它的完整调用链就彻底丢失了。这等于把最有价值的排查线索给扔了。正确做法finally 里的清理操作尽量用最稳妥的方式如果确实可能失败就自己再包一层 try...catch 记日志不要让它往外抛。5.3 日志记录时的异常上下文与数据脱敏把 context 放进异常对象固然好但直接记录有时会翻车。比如 context 里带了用户的身份证号、银行卡号或者用户密码的哈希这些内容写入日志后就是安全事故。所以我的规范是在 Context 里永远不放置敏感字段。如果确实要记录用户标识记录 user_id 就够了具体手机号之类需要时再去查。另外有一些框架会自动把异常对象转成数组或者 JSON 时调用getContext()如果所有异常都统一使用这个接口只要构建 context 的人注意规则整个日志体系就都是安全的。5.4 吞异常比不处理更危险的处理最后要强调一个极其常见的坏习惯——空 catch。我在 Code Review 的时候最怕看到这种代码?php try { // ... } catch (\Exception $e) { // 忽略 }这行注释比没有注释更可怕。你为了不让程序中断把异常吞掉了但异常的真正后果是数据不一致、状态错乱、后续逻辑拿着错误的数据运行最后以更隐蔽的方式爆炸。如果实在要吞也必须做两件事一是记录日志让异常至少留下痕迹二是明确注释为什么这里可以吞掉异常给出业务上的正当理由。6. 测试与最终落地保证异常体系长期不出问题在写代码层面异常定义得再好如果不配合测试时间一久还是会变质。我在团队里推行过一套针对异常体系的测试方式这里分享一些核心思路。6.1 异常类的单元测试主要测什么自定义异常类的单元测试不只是跑一下构造函数看能不能 new 出来。更重要的验证点是序列化与日志输出行为。?php public function testExceptionContextAndToString(): void { $exception new ExportException( 导出失败目标目录不可写, [dir /tmp/reports, user_id 123] ); $this-assertSame(导出失败目标目录不可写, $exception-getMessage()); $this-assertSame([dir /tmp/reports, user_id 123], $exception-getContext()); $string (string) $exception; $this-assertStringContainsString(EXPORT_ERROR, $string); $this-assertStringContainsString(/tmp/reports, $string); }这类测试看起来很基础但它可以防止以后有人重构异常类时把__toString()里的 context 输出搞丢也可以防止构造函数参数顺序变化导致调用方没跟上。一个异常体系长期可控靠的就是这些基础测试守着。6.2 在集成测试里验证异常路由如果你用了 Symfony 或者 Laravel 这类框架通常会有异常监听器或者异常渲染器。这部分也需要测试比如抛出一个PermissionDeniedException看最终 HTTP 响应是不是 403body 是不是符合约定的 JSON 结构。这类集成测试的收益有两个一个是确保异常到用户响应之间的映射稳定不会因为某个中间件顺序调整而失效另一个是保证异常体系里的错误码能被前端正确识别避免前后端联调时才发现消息格式对不上。6.3 上线之后的长期维护策略最后说一个很多团队容易忽略的问题异常体系是会膨胀的。半年之后项目里的异常类可能从 10 个涨到 50 个其中有相当一部分可能是重复的只是命名不同比如UserNotFoundInCacheException和CacheUserMissException。这时候怎么办我的建议是定期做异常类的审查就像做数据库表结构审查一样。可以借助 IDE 的Find Usages功能筛选出那些从未被捕获、从未被明确抛出的异常类该合并的合并、该删除的删除。异常类的命名也尽量形成团队约定通过动词/形容词 主语 Exception的格式一眼就能看出这是什么问题比如CannotCancelPaidOrderException就比OrderException好得多。我不是说团队必须把异常类当成正式产品来维护但它在后端系统里承担的职责确实接近于一份活的架构文档——每当你看到一个新的异常类出现就说明系统里又新增了一种必须被识别的失败场景。维护好这份文档整个团队都会因此受益。