
前言很多人在升级到 PHP 8.4 之后看到日志里突然冒出一堆从没见过的Deprecated:提示于是去搜PHP 8.4 新错误处理结果发现官方并没有一个叫新错误处理的东西。这正是本文要澄清的第一个问题PHP 8.4 没有引入新的错误类型也没有换掉try/catch这套机制。它做的是一组与错误、弃用deprecation直接相关的开关调整。第二个典型症状是线上接口 500日志里只有一句Uncaught Error堆栈指向框架内部业务代码一行都看不到或者反过来说某个函数明明写错了程序却只是默默跑过去返回一个null直到几小时后数据错乱才被发现。这两类问题都不是 PHP 版本造成的而是错误处理策略没定清楚哪些错误要中断、哪些要转异常、哪些只记日志、致命错误怎么兜底没有统一约定。本文按 PHP 8.4 的实际变更把现代 PHP 错误处理这套骨架讲清楚Throwable双轨制事实核对Throwable接口是PHP 7.0引入的不是 8.x 的新东西、Error与Exception的分工、set_error_handler()把警告转异常的规范写法、register_shutdown_function()兜住致命错误以及 8.4 新增的#[\Deprecated]属性怎么用。所有示例都是可直接保存为.php运行的 CLI 脚本最低要求 PHP 8.4。一、先把事实说清楚PHP 8.4 到底新在哪PHP 8.4 与错误处理直接相关的变更主要是下面这几条而不是一套新机制变更说明影响面新增#[\Deprecated]属性用户态代码可以像内置函数那样声明这个函数/方法/类常量已废弃触发E_USER_DEPRECATEDE_STRICT常量废弃该常量自 PHP 8.0 起已无实际语义代码里引用会报废弃trigger_error()传E_USER_ERROR废弃用用户级致命错误来中断流程的写法不再推荐触发E_DEPRECATED隐式可空参数废弃function f(Foo $x null)这种写法触发E_DEPRECATEDexit()/die()变成真正的函数可以像普通函数一样放进表达式、传参、当回调语法层面这里要点明一件事标题里的PHP 8.4 的新错误处理这个说法并不准确。真正奠定现代 PHP 错误处理骨架的是 PHP 7.0——那一年Throwable接口、Error类体系和Error/Exception双轨制被引入try/catch/finally从此能够捕获引擎级错误。PHP 8.4 只是在这个骨架上补了几个开关。所以本文讲的是8.4 环境下的规范做法而不是8.4 新发明的东西。二、Throwable 双轨制Error 与 Exception 的分工PHP 的错误分两条继承线它们共同实现Throwable接口Throwable (interface, PHP 7.0) ├── Exception │ ├── ErrorException │ ├── RuntimeException │ │ └── (业务异常一般继承这里) │ ├── InvalidArgumentException │ ├── LogicException │ └── ... └── Error ├── TypeError ├── ValueError (PHP 8.0) ├── ArithmeticError │ └── DivisionByZeroError ├── ArgumentCountError (PHP 7.1) ├── UnhandledMatchError (PHP 8.0) ├── ParseError └── AssertionError关键区分Exception是你的代码主动抛出来的Error是引擎发现代码本身有问题时抛出来的。TypeError、ValueError、DivisionByZeroError全部挂在Error这一支上。由此产生一个非常常见的漏网之鱼?php declare(strict_types1); function half(int $n): float { return $n / 2; } try { half(abc); // strict_types 下抛 TypeErrorTypeError 不是 Exception } catch (Exception $e) { echo 捕获到了\n; // 永远不会执行 }TypeError是Error的子类catch (Exception $e)抓不到它异常会一路冒到顶层变成Uncaught TypeError。正确写法是catch (Throwable $e)或者至少catch (Error $e)与catch (Exception $e)分开写因为两者的处置策略本来就该不同业务异常通常要转成用户可读的提示而Error一般意味着有 Bug应该原样上报。还有一条硬性限制用户态类不能直接implements Throwable。写class MyError implements Throwable {}会直接致命错误——必须改为继承Exception或Error。所以自定义异常基类应该是?php declare(strict_types1); // 业务异常可预期、可恢复 class DomainException extends RuntimeException {} // 系统异常不可预期需要人工介入 class InfraException extends RuntimeException {}三、把警告变成异常set_error_handler 的规范写法include失败、file_get_contents()读不到文件、除以零之外的各种数值告警在 PHP 里默认只发一个E_WARNING然后继续往下跑。返回值是false或null如果调用方没检查返回值Bug 就被吞掉了。标准做法是用set_error_handler()把这些非致命错误统一转成ErrorException?php declare(strict_types1); set_error_handler(function (int $no, string $msg, string $file, int $line): bool { // 该错误不在当前报告级别内包括被 抑制交回 PHP 默认处理器 if (!(error_reporting() $no)) { return false; } // 注意$no 是错误级别$code 是异常码两者不要混 throw new ErrorException($msg, 0, $no, $file, $line); }); $content file_get_contents(/definitely/not/here.txt); // 现在会抛 ErrorException三处细节必须注意ErrorException的构造函数签名是__construct(string $message, int $code, int $severity, string $filename, int $line)。第三个参数是$severity要传$no中间的$code传0。把$no塞到$code位置是很常见的笔误。return false表示我不处理交回默认处理器。如果无脑return true就等于把全站警告全部静音调试期会非常痛苦。PHP 8.0 起抑制符的语义变了它不再能屏蔽致命错误并且在作用域内error_reporting()返回的是一个固定的不可屏蔽掩码E_ERROR | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR | E_PARSE。所以上面的error_reporting() $no判断在 8.x 上依然正确但它对致命错误已经无效。四、致命错误也要兜住register_shutdown_functionE_ERROR、E_PARSE、E_CORE_ERROR、E_COMPILE_ERROR这四类错误无法被set_error_handler()拦截也无法被try/catch捕获。比如new一个不存在的类、调用不存在的函数、内存耗尽E_ERROR。要掌握这些错误只剩一条路register_shutdown_function()配合error_get_last()。?php declare(strict_types1); register_shutdown_function(function (): void { $err error_get_last(); if ($err null) { return; } $fatalMask E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR; if (($err[type] $fatalMask) 0) { return; // 普通警告不在这里处理 } fwrite(STDERR, sprintf( [FATAL] %s in %s on line %d\n, $err[message], $err[file], $err[line] )); // 关掉输出缓冲避免半截响应被当成正常结果 while (ob_get_level() 0) { ob_end_clean(); } });这里有一条铁律shutdown 回调里不要再抛异常。此时请求生命周期已结束没有上层try/catch会接手抛出去只会再变成一个Uncaught提示把真正的原始错误信息盖掉。shutdown 回调只做三件事记录日志、清理输出缓冲、调用exit()设置退出码。顺带说一个 8.4 的小便利exit()现在是一个真正的函数所以可以写成$x ?? exit(1)这种形式也可以传具名参数。它是错误处理链路的终点善用它把退出码语义定清楚0成功、1一般错误、2用法错误、255致命错误。五、单脚本实战一整套可运行的错误处理骨架把上面几块拼起来下面这个脚本可以直接php error_demo.php运行包含#[\Deprecated]、警告转异常、致命错误兜底三部分?php declare(strict_types1); // 最低要求PHP 8.4 // ---- 1. 自定义异常层级 ---- class DomainException extends RuntimeException {} class InfraException extends RuntimeException {} // ---- 2. 警告转异常 ---- set_error_handler(function (int $no, string $msg, string $file, int $line): bool { if (!(error_reporting() $no)) { return false; } throw new ErrorException($msg, 0, $no, $file, $line); }); // ---- 3. 致命错误兜底 ---- register_shutdown_function(function (): void { $err error_get_last(); if ($err null) { return; } if (($err[type] (E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR)) 0) { return; } fwrite(STDERR, [FATAL] {$err[message]} {$err[file]}:{$err[line]}\n); }); // ---- 4. PHP 8.4 新增给函数打上废弃标记 ---- #[\Deprecated(请改用 parseConfigV2())] function parseConfigV1(string $raw): array { return json_decode($raw, true) ?? []; } function parseConfigV2(string $raw): array { try { return json_decode($raw, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException $e) { throw new DomainException(配置不是合法 JSON, 0, $e); } } // ---- 5. 调用演示 ---- $cases [ fn () parseConfigV2({a:1}), // 正常 fn () parseConfigV2({不是json), // DomainException fn () file_get_contents(/nope/nope), // ErrorException fn () half(3), // 故意触发调用不存在的函数 - E_ERROR ]; function half(int $n): float { return $n / 2; } foreach ($cases as $i $case) { try { $result $case(); echo [{$i}] OK: . json_encode($result, JSON_UNESCAPED_UNICODE) . PHP_EOL; } catch (DomainException $e) { echo [{$i}] 业务错误: {$e-getMessage()}\n; } catch (ErrorException $e) { echo [{$i}] 运行环境错误(原级别 {$e-getSeverity()}): {$e-getMessage()}\n; } catch (Throwable $e) { echo [{$i}] 未预期: . get_class($e) . - {$e-getMessage()}\n; } finally { echo [{$i}] 处理结束\n; } } // 触发一次真正的致命错误调用不存在的函数观察 shutdown 兜底输出 call_to_undefined_function();运行后会看到0 号输出正常结果1 号落到DomainException分支并保留getPrevious()里的原始JsonException2 号落到ErrorException分支最后一个call_to_undefined_function()在try/catch之外由 shutdown 回调打印[FATAL]。这正是分级处理该有的样子。另外#[\Deprecated]的行为要理解清楚它只发提示不阻止执行。被标记的函数照常运行、照常返回结果只是每次调用都会产生一条E_USER_DEPRECATED在error_reporting包含该级别时可见。它替代的是过去那种在函数体里写trigger_error(..., E_USER_DEPRECATED)的土办法好处是静态分析工具和 IDE 也能读到这条信息。常见坑点1. 用catch (Exception $e)去抓引擎错误// ❌ TypeError / ValueError / DivisionByZeroError 都是 Error 的子类抓不到 try { $r 10 / 0; } catch (Exception $e) { echo 被除零异常抓住了; // 不会执行 }// ✅ try { $r 10 / 0; } catch (DivisionByZeroError $e) { echo 除零; } catch (Throwable $e) { echo get_class($e); // DivisionByZeroError }2. 自定义异常直接实现 Throwable// ❌ 致命错误Class MyError cannot implement interface Throwable class MyError implements Throwable {}// ✅ class MyError extends RuntimeException {}3. 在 set_error_handler 里无脑 return true// ❌ 全站警告被静音file_get_contents 失败也悄无声息 set_error_handler(fn () true);// ✅ 只在需要转异常时处理其余交回默认处理器 set_error_handler(function (int $no, string $msg, string $file, int $line): bool { if (!(error_reporting() $no)) { return false; } throw new ErrorException($msg, 0, $no, $file, $line); });4. 把 ErrorException 的 severity 塞进 code 位// ❌ $no 变成了异常码$severity 恒为 0后面按级别分流时全部错判 throw new ErrorException($msg, $no);// ✅ 位置是 (message, code, severity, filename, line) throw new ErrorException($msg, 0, $no, $file, $line);5. 在 shutdown 回调里继续抛异常// ❌ 抛出的 Uncaught 会盖掉原始的 [FATAL] 信息排查时看不到真凶 register_shutdown_function(function () { throw new RuntimeException(记录失败); });// ✅ 只记录 设退出码绝不抛 register_shutdown_function(function () { $err error_get_last(); if ($err ! null) { fwrite(STDERR, $err[message] . PHP_EOL); } exit(255); });6. 把异常消息原样输出给用户// ❌ 数据库连接串、绝对路径、SQL 片段全部泄漏 try { $pdo new PDO($dsn, $user, $pass); } catch (PDOException $e) { echo 出错了 . $e-getMessage(); }// ✅ 日志留全量响应给通用文案 可检索的追踪号 try { $pdo new PDO($dsn, $user, $pass); } catch (PDOException $e) { $traceId bin2hex(random_bytes(8)); error_log([{$traceId}] . $e-getMessage()); http_response_code(500); echo 服务暂时不可用追踪号{$traceId}; }7. 在finally里return把异常和返回值一起吃掉// ❌ finally 的 return 会覆盖 try 块中抛出的异常调用方以为一切正常 function f(): int { try { throw new RuntimeException(坏掉了); } finally { return 0; } } echo f(); // 输出 0异常消失// ✅ finally 只做清理不 return、不 throw function f(): int { $handle fopen(php://memory, r); try { throw new RuntimeException(坏掉了); } finally { fclose($handle); } }8. 用trigger_error(..., E_USER_ERROR)中断流程// ❌ PHP 8.4 起这种用法已被标记废弃而且它是不可捕获的无法被上层 catch if ($config null) { trigger_error(缺少配置, E_USER_ERROR); }// ✅ 抛异常让调用方决定怎么处理 if ($config null) { throw new DomainException(缺少配置); }总结关注点规范做法版本要点捕获引擎错误catch (Throwable $e)或Error/Exception分开 catchThrowable接口 PHP 7.0自定义异常继承RuntimeException/LogicException禁止implements Throwable全版本一致警告转异常set_error_handler()error_reporting() $no判断 ErrorException语义变更在 PHP 8.0致命错误兜底register_shutdown_function()error_get_last()四类E_*始终不可 catch标记废弃#[\Deprecated(替代方案)]属性只提示不阻断PHP 8.4 新增退出码exit(0/1/2/255)CLI 脚本必须显式设置PHP 8.4 起exit()是函数一句话结论PHP 8.4 没有给你新的错误处理它给你的是#[\Deprecated]这个更规范的废弃声明方式外加几条废弃提醒。真正决定代码健壮性的是有没有把Error与Exception分开、有没有把警告转成异常、有没有给致命错误留一条兜底日志。把这三件事定成团队约定比追新版本有用得多。