☰
PHP 8.4的新错误处理怎么用才规范
2026/10/1 11:05:34 网站建设 项目流程

前言

很多人在升级到 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_DEPRECATED
E_STRICT常量废弃该常量自 PHP 8.0 起已无实际语义代码里引用会报废弃
trigger_error()传E_USER_ERROR废弃用"用户级致命错误"来中断流程的写法不再推荐触发E_DEPRECATED
隐式可空参数废弃function f(Foo $x = null)这种写法触发E_DEPRECATED
exit()/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_types=1); function half(int $n): float { return $n / 2; } try { half('abc'); // strict_types 下抛 TypeError,TypeError 不是 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_types=1); // 业务异常:可预期、可恢复 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_types=1); 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

三处细节必须注意:


  1. ErrorException的构造函数签名是__construct(string $message, int $code, int $severity, string $filename, int $line)。第三个参数是$severity,要传$no;中间的$code传0。把$no塞到$code位置是很常见的笔误。

  2. return false表示"我不处理,交回默认处理器"。如果无脑return true,就等于把全站警告全部静音,调试期会非常痛苦。

  3. 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_function

E_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_types=1); 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_types=1); // 最低要求: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()里的原始JsonException,2 号落到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分开、有没有把警告转成异常、有没有给致命错误留一条兜底日志。把这三件事定成团队约定,比追新版本有用得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询