前言
从 PHP 7.x 升到 8.0 之后,最典型的三种症状是:
- 白屏:页面什么都没有,
display_errors又是关的,连一条错误都看不到; - 日志暴涨:升级前日志一天几十行,升级后一天几十万行,全是
Deprecated和Warning; @失灵:以前用@压住的写法突然开始报致命错误,脚本直接中断;- 原来的"警告 + 返回 null"变成了异常:
strlen([])这种老代码从"悄悄返回 null"变成抛TypeError,被最外层的catch (Exception $e)漏掉,直接 500。
原因是 PHP 8.0 动了好几处与错误处理直接相关的默认行为:默认错误级别、内部函数的类型检查、致命错误的可捕获性、以及@抑制符的作用范围。这些变化不会让代码编译不过,只会让运行期的错误以全新的形态出现。
本文讲四件事:PHP 8.0 到底改了哪些错误相关的行为、各种运行模式下日志到底在哪、怎么把错误变成可检索的结构、以及一段可以直接跑的升级体检脚本。
一、PHP 8.0 改了哪些与错误有关的行为
先看对照表,这张表就是"升级后为什么日志突然不一样"的答案。
| 行为 | PHP 7.x | PHP 8.0 起 |
|---|---|---|
默认error_reporting | E_ALL & ~E_DEPRECATED & ~E_STRICT | E_ALL |
| 内部函数参数类型不符 | Warning,函数返回null | 抛TypeError |
| 未定义常量 | Warning(7.2 起),并当作字符串用 | 抛Error |
| 致命错误 | 直接中断,不可捕获 | 是Error的子类,可被catch (Throwable)捕获 |
@抑制符 | 抑制所有错误 | 不再抑制致命错误 |
| 数字与字符串比较 | "abc" == 0为true | 为false |
| 数组转字符串 | Notice | Warning |
match无匹配分支 | — | 抛UnhandledMatchError |
第一行最容易被忽略:默认级别从"不含弃用"改成了E_ALL。也就是说,同一份代码在 7.x 上跑得好好的,升级后弃用信息会突然出现在日志里——不是代码变差了,是默认值变了。对升级来说这其实是好事,它把本来就该看的弃用信息推到了你面前。
同时,PHP 8.0 移除了一批函数,调用它们会直接Fatal error: Call to undefined function:create_function()、each()、money_format()、get_magic_quotes_gpc()、hebrevc()、convert_cyr_string()、fgetss()。升级后第一件事就是用function_exists()在日志里扫一遍这几个名字。
顺带记一个 PHP 8.0 新增的、写日志时非常好用的函数:get_debug_type()。它比gettype()更具体(对象返回完整类名而不是"object"),比get_class()更安全(对非对象不会报错)。
| 值 | gettype() | get_debug_type() |
|---|---|---|
1 | integer | int |
1.0 | double | float |
true | boolean | bool |
null | NULL | null |
new Foo() | object | Foo(完整类名) |
| 文件句柄 | resource | resource (stream) |
二、日志到底在哪里
升级后"改了 php.ini 却感觉没生效",绝大多数情况是看错了日志文件。按运行模式对号入座:
| 运行模式 | 日志位置 | 怎么确认 |
|---|---|---|
| 内置服务器 / CLI | 标准错误(stderr) | 直接看终端输出,或php -r '...' 2>err.log |
| PHP-FPM | php.ini的error_log,或 pool 配置里的php_admin_value[error_log] | 看 phpinfo() 的error_log那一行 |
| Nginx + FPM | Nginx 的error_log只记网关错误;PHP 的错误在 FPM 日志里 | 两边都要看 |
| Apache + mod_php | ErrorLog指令指定的文件 | apache2ctl -S看配置来源 |
| Docker | 容器的 stderr(docker logs) | 容器内 PHP 的 stdout/stderr 就是容器日志 |
| systemd | journalctl -u php8.0-fpm -f | 服务名按实际版本改 |
几个常用的确认命令:
# 当前 CLI 用的 php.ini 在哪 php --ini # 打印与错误相关的所有配置当前值 php -i | grep -Ei '^(error_reporting|display_errors|log_errors|error_log|log_errors_max_len)' # 实时跟踪 FPM 的错误日志 tail -f /var/log/php-fpm/www-error.log # 只筛出致命错误和未捕获异常 grep -E 'Fatal error|Uncaught' /var/log/php-fpm/www-error.log | tail -n 50最关键的坑:php -i打印的是CLI的配置,而网站跑的是FPM的配置,两者读的可能是完全不同的php.ini。升级后觉得"配置明明改了",先确认你tail的那个文件真的是 FPM 在写的那个。另外 FPM 里子进程的输出默认被丢弃,需要在 pool 配置里打开catch_workers_output = yes才能进主日志:
; php-fpm.d/www.conf catch_workers_output = yes php_admin_value[error_log] = /var/log/php-fpm/www-error.log php_admin_flag[log_errors] = on php_admin_flag[display_errors] = off注意 pool 配置里的php_admin_value/php_admin_flag优先级高于 php.ini,而且不能用ini_set()在运行时覆盖。所以"我在代码里写了ini_set('display_errors', '0')为什么没用",答案通常就在这里。
三、把错误变成可检索的结构
光有日志文件不够。PHP 默认的错误行是纯文本,混着各种格式,很难筛、很难统计。更有效的做法是用三个钩子把错误统一成结构化日志:
| 钩子 | 负责什么 | 注意 |
|---|---|---|
set_error_handler() | Warning/Notice/Deprecated | 抓不到E_ERROR、E_PARSE、E_CORE_ERROR、E_COMPILE_ERROR |
set_exception_handler() | 未捕获的Throwable | 覆盖默认的 "Uncaught ... Fatal error" 输出 |
register_shutdown_function() | 最后兜底,用error_get_last()拿致命错误 | 这是唯一能"看到"致命错误的途径 |
三者配合才能覆盖全部错误。另外记住:Throwable这个公共接口是PHP 7.0引入的,它同时覆盖Error和Exception两条继承链——升级后catch (Exception $e)抓不到TypeError,必须换成catch (Throwable $e)。
四、实战:统一错误采集器 + 升级体检
下面这份采集器把上面三个钩子装进一个类,输出 JSON Lines(一行一条 JSON),方便直接喂给日志系统做聚合。最低版本PHP 8.0。
<?php declare(strict_types=1); /** * PHP 8.0 统一错误采集器 * 最低版本:PHP 8.0(用到 get_debug_type() 和构造器属性提升) */ final class ErrorCollector { /** 这些级别会被转成异常,走统一处理 */ private const FATAL_LEVELS = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR]; public function __construct( private string $logFile, private bool $throwOnWarning = true, ) {} public function register(): void { set_error_handler([$this, 'handleError']); set_exception_handler([$this, 'handleException']); register_shutdown_function([$this, 'handleShutdown']); } public function handleError(int $severity, string $message, string $file = '', int $line = 0): bool { // 被 @ 或 error_reporting 屏蔽的,交回 PHP 默认处理 if (!(error_reporting() & $severity)) { return false; } // 弃用信息只记录,不抛异常:否则线上会因为一条 Deprecated 直接 500 if ($severity === E_DEPRECATED || $severity === E_USER_DEPRECATED) { $this->write('deprecated', [ 'message' => $message, 'file' => $file . ':' . $line, ]); return true; } if ($this->throwOnWarning) { throw new ErrorException($message, 0, $severity, $file, $line); } $this->write('warning', ['message' => $message, 'file' => $file . ':' . $line]); return true; } public function handleException(Throwable $e): void { $this->write('exception', [ // get_debug_type() 是 PHP 8.0 新增的:对象给完整类名,标量给 int/float/bool 'class' => get_debug_type($e), 'message' => $e->getMessage(), 'file' => $e->getFile() . ':' . $e->getLine(), 'trace' => $e->getTraceAsString(), ]); if (!headers_sent()) { http_response_code(500); } } public function handleShutdown(): void { $last = error_get_last(); if ($last === null || !in_array($last['type'], self::FATAL_LEVELS, true)) { return; } $this->write('fatal', [ 'message' => $last['message'], 'file' => $last['file'] . ':' . $last['line'], ]); } private function write(string $channel, array $context): void { $line = json_encode( ['time' => date('c'), 'channel' => $channel] + $context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR ); // error_log() 的第二个参数为 3 表示"追加到指定文件", // 它不受 log_errors 开关影响,也不负责加换行,所以要自己补 PHP_EOL error_log($line . PHP_EOL, 3, $this->logFile); } } // ---------------- 使用示例 ---------------- $collector = new ErrorCollector(__DIR__ . '/app-error.log', throwOnWarning: true); $collector->register(); // 1) PHP 8.0 起,内部函数参数类型不符会抛 TypeError(7.x 只是 Warning + 返回 null) try { strlen([]); } catch (TypeError $e) { printf("捕获 TypeError: %s\n", $e->getMessage()); } // 2) 数字与字符串比较的行为变了:7.x 下 "abc" == 0 为 true var_dump('abc' == 0); // PHP 8.0 起:false // 3) match 无匹配会抛 UnhandledMatchError(继承自 Error) try { $x = match ('x') { 1 => 'one' }; } catch (UnhandledMatchError $e) { printf("捕获 UnhandledMatchError: %s\n", $e->getMessage()); } // 4) 用 get_debug_type() 打到日志里,比 gettype() 有用得多 $collector->handleException(new InvalidArgumentException('演示用异常')); echo file_get_contents(__DIR__ . '/app-error.log');配套的升级体检脚本,把"当前环境是否已经适配 8.0"逐项打出来:
<?php declare(strict_types=1); /** * PHP 8.0 升级自检 * 用法:php p22-upgrade-check.php */ $checks = [ ['error_reporting 是全量 E_ALL', error_reporting() === E_ALL, '→ 检查 ini 的 error_reporting'], ['display_errors 已关闭', in_array((string) ini_get('display_errors'), ['0', ''], true), '→ 生产必须关闭,避免堆栈泄露给用户'], ['log_errors 已打开', ini_get('log_errors') === '1', '→ 打开才会记录 PHP 自身的错误'], ['error_log 已指定文件', (string) ini_get('error_log') !== '', '→ 未指定时走 SAPI 默认通道'], ['create_function 已不存在', !function_exists('create_function'), '→ PHP 8.0 已移除'], ['each 已不存在', !function_exists('each'), '→ PHP 8.0 已移除'], ['money_format 已不存在', !function_exists('money_format'), '→ PHP 8.0 已移除'], ['get_debug_type 可用', function_exists('get_debug_type'), '→ PHP 8.0 新增,可替代 gettype'], ['json_validate 可用', function_exists('json_validate'), '→ 需要 PHP 8.3,8.0 上不要用'], ]; $failed = 0; foreach ($checks as [$name, $ok, $hint]) { if (!$ok) { $failed++; } printf("[%s] %s %s\n", $ok ? ' OK ' : 'FAIL', $name, $ok ? '' : $hint); } printf("\nPHP %s (%s),共 %d 项,失败 %d 项\n", PHP_VERSION, PHP_SAPI, count($checks), $failed);把这两个脚本在CLI和FPM下各跑一次(FPM 下通过一个临时路由访问),对照输出的差异,就能确认"我改的配置到底有没有生效"。
常见坑点
1. 以为@还能压住一切
❌ 错误写法:
<?php $content = @file_get_contents($logPath); // 路径不存在时,8.0 起致命错误照样中断✅ 正确写法:
<?php try { $content = file_get_contents($logPath); } catch (Throwable $e) { // 8.0 起致命错误是 Error 对象,能被捕获 error_log('读取失败: ' . $e->getMessage()); $content = ''; }2. 用catch (Exception $e)兜底
❌ 错误写法:
<?php try { $len = strlen($input); } catch (Exception $e) { // TypeError 继承自 Error,不是 Exception,抓不到 // 永远进不来 }✅ 正确写法:
<?php try { $len = strlen($input); } catch (Throwable $e) { // Throwable 是 PHP 7.0 引入的公共接口 // Error 和 Exception 都能抓到 }3. 把弃用信息也抛成异常
❌ 错误写法:set_error_handler里对所有级别一律throw new ErrorException(...)。结果:线上一个Deprecated就把整个接口打成 500,而且报错内容与业务毫无关系。
✅ 正确写法:弃用只记录,不抛异常(见上一节采集器里的E_DEPRECATED分支)。把"升级后要清理的清单"和"要立刻中断的错误"分开对待。
4. 以为log_errors=Off会让error_log()不写
❌ 错误理解:关掉log_errors就能让代码里所有error_log()静音。
✅ 事实:log_errors只控制PHP 自身的错误往哪写;error_log()是一个函数,它按自己的参数干活,尤其error_log($msg, 3, $file)这种"追加到文件"的形式完全不受log_errors影响。要静音只能改代码或换消息类型。
5. 在代码里ini_set想覆盖 FPM 的 pool 配置
❌ 错误写法:
<?php ini_set('display_errors', '0'); // pool 里写了 php_admin_flag[display_errors]=on 时无效✅ 正确写法:php_admin_value/php_admin_flag优先级高于ini_set(),改配置要去php-fpm.d/*.conf。要确认最终生效值,跑一个只输出phpinfo()的临时页面看,别信php -i的输出——那是 CLI 的。
6. 只 tail Nginx 日志
❌ 错误做法:升级后 500,只盯着/var/log/nginx/error.log看,里面只有一句FastCGI sent in stderr: "PHP message: ..."或者什么都没有。
✅ 正确做法:Nginx 的日志是网关视角,PHP 的错误在 FPM 侧。要么查 FPM 的error_log,要么在 pool 里打开catch_workers_output = yes把子进程输出收进主日志。
7. 记录日志时json_encode返回false,日志行变成空的
❌ 错误写法:
<?php error_log(json_encode($context) . PHP_EOL, 3, $logFile); // $context 里混入非法 UTF-8 时,json_encode 返回 false,日志里只剩一个空行✅ 正确写法:
<?php $line = json_encode($context, JSON_PARTIAL_OUTPUT_ON_ERROR | JSON_UNESCAPED_UNICODE); error_log(($line === false ? '{"error":"encode_failed"}' : $line) . PHP_EOL, 3, $logFile);JSON_PARTIAL_OUTPUT_ON_ERROR能让编码尽量完成,用U+FFFD顶替坏字节——比丢掉整条日志强。
8. 在register_shutdown_function里访问不存在的变量
❌ 错误写法:
<?php register_shutdown_function(function () use ($e) { error_log($e->getMessage()); // 闭包外的 $e 根本不存在,作用域里拿不到 });✅ 正确写法:致命错误只能通过error_get_last()取:
<?php register_shutdown_function(function (): void { $last = error_get_last(); if ($last !== null && in_array($last['type'], [E_ERROR, E_PARSE, E_COMPILE_ERROR], true)) { error_log("Fatal: {$last['message']} @ {$last['file']}:{$last['line']}"); } });总结
| 升级后遇到的现象 | 根本原因 | 处理方向 |
|---|---|---|
日志里突然全是Deprecated | error_reporting默认变成E_ALL | 把弃用单独收集统计,不要混进错误处理 |
Call to undefined function | 8.0 移除了each/create_function/money_format等 | function_exists()全量扫描后替换 |
| 老代码突然 500 | 内部函数类型不符由Warning变成TypeError | 用catch (Throwable)兜底,修掉类型错误的调用 |
@压不住的致命错误 | 8.0 起@不再抑制致命错误 | 用try/catch加register_shutdown_function |
| 改了 php.ini 不生效 | php_admin_value覆盖,或看的是 CLI 的配置 | 用phpinfo()确认 FPM 的最终生效值 |
| 白屏,日志里什么都没有 | 日志看错了文件,或 FPM 丢弃了子进程输出 | catch_workers_output、查 FPM 自己的error_log |
升级 PHP 8.0 之后的第一件事,不是改代码,而是确认错误看得见:error_reporting=E_ALL、log_errors=On、display_errors=Off、三个错误钩子全部注册。等日志能稳定地把TypeError、Deprecated、UnhandledMatchError分门别类地记下来,升级剩下的工作就只是照着清单一条条改了。