
前言PHP 7.2 已经停止安全支持很久了继续跑在这上面意味着不再收到任何安全补丁。真把一套 7.2 时代的代码搬到 PHP 8.x 上症状通常分成两波而且第二波比第一波麻烦得多。第一波是立刻白屏语法层面的问题会在文件被解析的瞬间抛Parse Error比如$str{0}这种花括号取字符的写法在 8.0 已经被移除。这类问题定位很快因为报错直接给出行号。第二波是不报错的静默行为变化这才是不兼容最阴险的地方。举三个真实例子0 abc在 PHP 7.x 里是true在 8.0 之后是false一句权限判断可能就此反转count(null)在 7.2 下只发一条警告返回 1在 8.0 下直接抛TypeError中断请求curl_init()返回的从资源resource变成了对象CurlHandle所有is_resource($ch)的判断都会变成false代码悄悄走进另一条分支。这三个问题都不会在语法检查时暴露。快速修复的关键不在于把报错一个个改掉而在于用工具把问题先找全再按风险分级处理。本文给出完整流程先用静态扫描和运行时钩子建立问题清单再按兼容性对照表批量改造最后按版本梯度验证。一、先建立问题清单1.1 静态扫描把危险写法全找出来PHP 的 linter 只检查语法php -l在 7.2 下能通过的文件不代表在 8.x 下没问题。真正有用的是兼容性嗅探工具比如 PHPCompatibilityPHP_CodeSniffer 的一个规则集?xml version1.0? !-- phpcs.xml —— 放在项目根目录 -- ruleset nameLegacy Compatibility description扫描 PHP 8.0 兼容性问题/description filesrc/file fileapp/file exclude-pattern*/vendor/*/exclude-pattern exclude-pattern*/tests/*/exclude-pattern rule refPHPCompatibility/ config nametestVersion value8.0-/ /ruleset配合命令行使用./vendor/bin/phpcs --reportsummary --report-filecompat.txt ./vendor/bin/phpcs --reportsource--reportsource会按问题类型汇总一眼就能看出是移除的函数占多数还是参数签名变更占多数据此决定是先批量替换还是先逐个评审。testVersion的值可以写成7.2-8.0表示两个版本的差异都要报出来。1.2 运行时钩子抓住那些不抛异常的弃用静态扫描找不出动态调用的情况比如call_user_func($name)或者反射生成的调用。这时候要在运行时兜住把服务切到目标 PHP 版本开满错误报告用一个处理器把弃用信息连同调用栈一起记下来。?php // deprecation-logger.php —— 在应用入口最前面 requirePHP 7.2 declare(strict_types1); error_reporting(E_ALL); ini_set(display_errors, 0); ini_set(log_errors, 1); set_error_handler(static function (int $errno, string $message, string $file, int $line): bool { // 只接管弃用类错误其余交回 PHP 默认处理器 if ($errno ! E_DEPRECATED $errno ! E_USER_DEPRECATED) { return false; } // 取调用栈定位是谁触发了这个弃用 $trace debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 6); array_shift($trace); $frames []; foreach ($trace as $frame) { if (isset($frame[file], $frame[line])) { $frames[] $frame[file] . : . $frame[line]; } } error_log(sprintf( [DEPRECATED] %s %s:%d | trace: %s, $message, $file, $line, implode( - , $frames) )); return true; // 吞掉避免污染页面输出 }); // 同时把警告和通知也收进日志升级期这些往往就是未来的致命错误 set_exception_handler(static function (Throwable $e): void { error_log(sprintf( [UNCAUGHT] %s: %s %s:%d, get_class($e), $e-getMessage(), $e-getFile(), $e-getLine() )); }); // 下面这行在 PHP 8.x 上会触发弃用字符串与数字的宽松比较用来验证钩子是否生效 if (0 legacy-flag) { echo 宽松比较判定相等\n; } else { echo 宽松比较判定不等\n; }在 PHP 7.2 下运行输出宽松比较判定相等在 PHP 8.0 之后输出宽松比较判定不等同时日志里会多一条[DEPRECATED]。这个差异本身就是最危险的兼容性问题之一因为它不报错、只是让逻辑悄悄走反。把这个处理器挂到预发布环境跑一两天日志里积累的弃用条目就是一份按真实流量统计出来的改造清单比任何静态分析都贴近生产。1.3 用 polyfill 换取过渡期如果项目需要在一段时间内同时兼容 7.2 和 8.x典型的灰度发布场景把新增的内置函数用function_exists()包一层补上可以让同一份代码在两边都跑?php // polyfills.php —— 必须在所有业务代码之前加载 declare(strict_types1); if (!function_exists(str_contains)) { function str_contains(string $haystack, string $needle): bool { return $needle || strpos($haystack, $needle) ! false; } } if (!function_exists(str_starts_with)) { function str_starts_with(string $haystack, string $needle): bool { return strncmp($haystack, $needle, strlen($needle)) 0; } } if (!function_exists(str_ends_with)) { function str_ends_with(string $haystack, string $needle): bool { if ($needle ) { return true; } return substr($haystack, -strlen($needle)) $needle; } } if (!function_exists(array_key_first)) { // 原生支持自 PHP 7.3 function array_key_first(array $array) { foreach ($array as $key $_) { return $key; } return null; } } if (!function_exists(is_countable)) { // 原生支持自 PHP 7.3 function is_countable($value): bool { return is_array($value) || $value instanceof Countable; } } // 验证 var_dump(str_contains(hello world, world)); var_dump(str_starts_with(hello world, hello)); var_dump(str_ends_with(hello world, world)); var_dump(array_key_first([a 1, b 2])); var_dump(is_countable(new ArrayObject([1, 2])));输出bool(true) bool(true) bool(true) string(1) a bool(true)polyfill 文件要通过auto_prepend_file或者 Composer 的files自动加载项在所有代码之前生效否则可能在某个请求路径上漏加载。过渡期结束后记得删掉因为它会永久掩盖服务器上到底装没装新版 PHP这个事实。二、按风险分级改造2.1 兼容性对照表下面这张表按出问题时的排查难度排序越靠上的越该优先处理老写法7.2 可用新写法8.0后果$str{0}$str[0]8.0 移除解析期直接报错create_function($a, return $a;)function ($a) { return $a; }8.0 移除each($array)foreach8.0 移除0 abc0 abc或显式类型转换8.0 起结果反转为 falsecount($maybeNull)is_countable($v) ? count($v) : 08.0 起抛 TypeErroris_resource($curl)$curl instanceof CurlHandle8.0 起句柄是对象$php_errormsgerror_get_last()[message]8.0 移除money_format()NumberFormatter需 intl8.0 移除implode($pieces, $glue)implode($glue, $pieces)8.0 移除旧参数顺序parse_str($query)parse_str($query, $result)8.0 移除单参数形式get_magic_quotes_gpc()直接删除这段判断8.0 移除array_key_exists($k, $obj)property_exists($obj, $k)8.0 移除对对象的支持${$name}字符串插值{$name}或拼接8.0 移除hebrevc()/convert_cyr_string()用iconv重写8.0 移除get_class()无参数调用$this::class8.3 起弃用is_resource()这一条值得展开说。PHP 8.0 把一批内置扩展的句柄从资源改成了对象curl_init()返回CurlHandleimagecreatetruecolor()返回GdImagefsockopen()返回Socketxml_parser_create()返回XMLParser。老代码里常见的if (!is_resource($ch)) { throw ... }会在升级后无条件抛异常而is_resource($ch)这类静默判断则会走进错误的 else 分支。2.2 自动改造与人工复核Rector 能做大部分机械替换它的配置形如?php // rector.php —— 放在项目根目录 declare(strict_types1); use Rector\Config\RectorConfig; use Rector\Set\ValueObject\LevelSetList; return RectorConfig::configure() -withPaths([__DIR__ . /src]) -withSets([ LevelSetList::UP_TO_PHP_74, LevelSetList::UP_TO_PHP_80, ]);./vendor/bin/rector process --dry-run # 先看会改哪些文件 ./vendor/bin/rector process # 确认后落地工具类的配置 API 在不同大版本之间有过调整具体类名和命名空间以官方文档为准别照抄网上几年前的片段。更重要的是要清楚Rector 的能力边界它能安全地把$str{0}改成$str[0]能把each()循环换成foreach但它判断不了0 $userInput这种表达式在业务上原本想表达什么。凡是涉及比较语义、类型假设、错误处理流程的改动必须人工过一遍。2.3 一个典型的重构例子老代码里each()配while是常见写法而且它的语义和foreach并不完全一样each()会推进数组内部指针。如果循环中途用next()、current()操作过同一个数组直接换成foreach会改变行为。?php // each-to-foreach.php —— 改造前后对比需要 PHP 8.0因为用到了 mixed 类型 declare(strict_types1); $config [host 127.0.0.1, port 3306, user root]; // 旧写法依赖 each()PHP 8.0 起不可用 // reset($config); // while ([$key, $value] each($config)) { // echo {$key} {$value}\n; // } // 新写法语义等价且不再依赖数组内部指针 foreach ($config as $key $value) { echo {$key} {$value}\n; } echo PHP_EOL; // 需要同时拿到键和值时用 array_map 或直接构造新数组 $pairs []; foreach ($config as $key $value) { $pairs[] sprintf(%s%s, $key, $value); } echo implode(, $pairs), PHP_EOL; // count 的防御性写法null 和不可数对象都不会再抛 TypeError function safeCount(mixed $value): int { return is_countable($value) ? count($value) : 0; } var_dump(safeCount(null)); var_dump(safeCount([a, b])); var_dump(safeCount(new stdClass()));输出host 127.0.0.1 port 3306 user root host127.0.0.1port3306userroot int(0) int(2) int(0)safeCount()里的mixed类型是 PHP 8.0 引入的如果过渡期还要兼容 7.2把参数类型去掉即可逻辑完全一样。三、依赖和环境的同步升级代码改完不代表能跑起来Composer 的依赖树往往是最后的拦路虎。先看看到底是谁挡着不让升composer why-not php 8.0 composer check-platform-reqs composer outdated --directcomposer why-not php 8.0会列出所有在composer.json里声明了不兼容 PHP 8.0 的包这是最直接的一份升级阻塞清单。处理原则是能升的升到支持 8.x 的版本已经废弃没人维护的就找替代品或者 fork 一份。同时别忘了环境侧的配套项; php.ini 的关键项升级后行为有变化 error_reporting E_ALL display_errors Off log_errors On track_errors Off ; PHP 8.0 起该指令已移除保留会报未知指令track_errors在 PHP 8.0 被移除$php_errormsg变量也随之消失。老代码里if ($php_errormsg) { ... }这种写法会直接变成未定义变量而且因为$php_errormsg曾经是超全局变量静态扫描不一定能找全运行时钩子才是可靠手段。常见坑点❌ 直接把老代码扔到 PHP 8 上跑看到报错才改 ✅ 先开error_reporting(E_ALL)用set_error_handler收集E_DEPRECATED因为大量问题是弃用通知而不是异常不开日志根本看不见❌ 用php -l通过就认为文件兼容新版本 ✅ linter 只检查语法检查不出0 abc的语义反转、count(null)的 TypeError、is_resource()的返回值变化❌ 用 IDE 的全局替换把$var{0}换成$var[0]✅ 字符串和数组都用花括号取下标正则要匹配上下文否则会把$obj{0}这种本来就有歧义的写法改错❌ 用count($data)直接处理可能是null的变量 ✅ PHP 8.0 起对不可数值调用count()抛TypeError改用is_countable($data) ? count($data) : 0或者在源头保证?? []❌ 保留is_resource($ch)来判断 curl / gd 句柄是否有效 ✅ PHP 8.0 起这些句柄是对象CurlHandle、GdImage判断改为$ch instanceof CurlHandle或者直接判断! false❌ 把while (list($k, $v) each($arr))直接改成foreach✅each()会推进数组内部指针如果循环体里用过next()/current()/reset()语义会变必须整体重写而不是逐行替换❌ 用 Rector 或任意工具批量改完就直接上线 ✅ 工具只能处理它认识的模式比较语义和错误处理逻辑必须人工复核改完跑全量测试❌ 升级代码却忘了检查 Composer 依赖 ✅ 先跑composer why-not php 8.0拿到阻塞清单把依赖升级和新版本代码放在同一次发布里❌ 用抑制符号来搞定升级后的报错 ✅ PHP 8.0 起不再抑制致命错误而且它会把弃用信息一并吞掉让真正的改造点永远浮不出来总结阶段工具 / 手段产出摸底PHPCompatibility phpcs --reportsource静态问题清单和类型分布运行时取证set_error_handler收集E_DEPRECATED及调用栈按真实流量统计的改造优先级过渡兼容function_exists()包裹的 polyfill同一份代码兼容 7.2 与 8.x批量改造Rector 的LevelSetList机械替换自动完成人工复核对照兼容性表格逐项确认语义类问题清单依赖同步composer why-not php 8.0阻塞包清单与替代方案验证预发布环境开满错误报告跑一遍剩余弃用与警告老代码升级 PHP 版本从来不是改报错而是找那些不报错的地方。把静态扫描和运行时钩子结合起来先拿到一份完整清单再让工具处理机械替换、人工处理语义判断整个过程就能从边跑边炸变成一次性收敛。真正需要小心的永远不是白屏的那几个Parse Error而是0 abc这类安静地把逻辑走反的改动。