PHP 8 下维护一套老系统:静默失效与致命错误实录

接手一套跑了十多年的 PHP 系统,第一件事往往不是加功能,而是”让它在新版 PHP 上别崩”。这类迁移的坑特别有迷惑性:有些是直接白屏,有些是页面看起来正常、数据悄悄错了,还有些是”只有某一个入口才会踩”。

本文记录一次从 PHP 5/7 时代代码迁到 PHP 8 的实战,包括几类反复出现的问题和一套可复用的排查方法。

一、@ 挡不住致命错误

现象:某个页面突然 Fatal error: Uncaught TypeError: extract(): Argument #1 ($array) must be of type array, null given。

代码里明明加了 @extract($settings)。

根因:@ 运算符只能抑制 warning / notice,抑制不了 PHP 8 里的 TypeError(它是 Throwable)。而这个位置原本还承担了一个”自愈”职责:缓存文件缺失时值为 null,随后由下游逻辑触发缓存重建。加 @ 是为了”缺缓存时别吵”,结果 PHP 8 把它变成了致命错误,把整条自愈路径掐断了。

修法:不要依赖 @,显式兜底并主动标记需要重建:

1
2
3
4
if (!is_array($cache['settings'])) {
$cache['settings'] = [];
$cache['cachelost'] = 'settings'; // 触发下游重建
}

教训:升级前把所有 @ 后面的调用列一遍,逐个判断”失败时是不是致命错误”。

二、parse_str 的单参形式已被移除

现象:某个功能页面 100% 报 500,连管理员都打不开。

根因:parse_str($str) 这种”只传一个参数”的写法在 PHP 8 已移除(它本该有第二个”输出到”的参数)。旧代码用它把查询串拆进当前作用域,升级后直接致命。

排查价值:这类 API 移除不会渐进降级,是硬失败。用静态扫描先过一遍会很省事:

1
grep -rnE '\b(mysql_|ereg|each\(|create_function|get_magic_quotes|utf8_encode|parse_str\([^,)]*\))' --include=*.php .

一次扫出全站同类点,比等用户报障快得多。

三、花括号下标与变量变量

两个看起来不起眼、但在老代码里密度很高的写法:

1
2
$str{0}              // PHP 8 已移除,改 $str[0]
$$setting['name'] // 变量变量:先取 $setting['name'] 再当变量名

前者会直接报语法错误(php -l 能抓到);后者更阴——它在 PHP 8 下不报错,但赋值目标与预期完全不同,表现为”某个配置项永远读不到”。

做法:php -l 全量跑一遍能解决第一类;第二类只能靠 grep + 人工确认。

四、对象初始化缺失 → 整站致命

现象:某个模块一加载,整站白屏,日志里是 Attempt to assign property "menu" on null。

根因:语言包里有一行”菜单复用”的写法:

1
$lang->xxx->menu = $lang->admin->menu;

如果 $lang->xxx 本身没有被初始化(缺少 new stdClass()),这一行就是对 null 赋值。而这段代码在框架的公共初始化里,一崩就是整站。

为什么升级后才出现:新版的语言文件删掉了原来的对象初始化行——它依赖新框架做了预初始化。旧框架没有这一步,于是整条链路断掉。

修法:补回初始化,并且形成规则:任何新增模块,必须先在公共语言文件的初始化块里创建对象。

五、cookie 白名单:三个反直觉

框架里有一个”参数过滤”配置,本质是 cookie 白名单。它有三个反直觉点,每一个都能让人查半天:

  1. 未登记的 cookie 会被静默丢弃,不报错。表现为”某个开关永远不生效”。
  2. 规则键必须用小写方法名。框架取到的方法名是 exportcase(全小写),写成驼峰 exportCase 匹配不上。
  3. 父级对象必须先创建。直接写子级规则,会触发整站致命错误——我们中途把测试站弄挂过一次。

第三点尤其要注意:配置写错不是”这条规则不生效”,而是”整站打不开”。

六、把对象当字符串 printf

现象:访客访问某些页面,返回的不是”无权限”提示,而是带完整文件路径的 Fatal 错误页。

根因:权限提示模板里有一行把”语言组对象”当成”方法的中文名”去做格式化输出。正常情况下取到的是字符串;一旦某个键名与同模块的方法名撞车,取到的就是对象,printf 直接崩。

风险:错误页会暴露服务器绝对路径——这既是可用性问题,也是信息泄露。

修法:取值时加类型守卫(is_string()),并为这类”错误处理路径本身出错”的场景补一条兜底:错误处理代码必须假设自己拿到的都是脏数据。

七、CLI 入口和 Web 入口不是一回事

现象:文档里写着的升级命令,执行完退出码 0、没有任何输出,看起来像成功,实际什么都没做。

根因:那段逻辑依赖一个只在 Web 入口赋值的变量(当前安装版本号)。CLI 入口下该变量为空,代码判定”来源版本非法”,于是拒绝执行——但它既没抛错也没打印,直接静默返回。

修法:让 CLI 路径自己从配置里读版本,而不是复用 Web 入口的全局状态。

教训:“退出码 0”不等于”做成了”。凡文档里的命令,验收标准必须是”有可观测的副作用”,比如数据库版本号变了、文件时间戳变了。

八、视图移植了、控制层没跟上

做界面层移植时很容易只搬模板(view/),因为”看起来变了”。但模板里用到的变量是控制层赋的,两边版本一旦错位,就会出现”页面结构对了、内容是空的”。

典型表现是页面渲染只剩骨架,或者某个区块整块消失。这类问题的排查顺序应该是:

  1. 先看渲染出来的 HTML 有没有内容(别只看 HTTP 200);
  2. 再看模板要的变量名,控制层到底赋了没有;
  3. 最后才是 CSS/JS。

实测数据:一次移植后做双容器对比扫射(旧版 vs 新版逐路由抓正文长度),177 个路由里能定位出”内容缩水 90% 以上”的空壳页——这类页面的 URL、状态码、布局断言全都正常,只有比对正文长度才抓得到。

九、可复用的排查顺序

维护老系统时,下面这套顺序能覆盖绝大多数问题:

  1. php -l 全量——先消灭语法级问题,几十秒就能跑完上千文件;
  2. 静态扫描已移除 API——把”必然崩”的点一次性清掉;
  3. 双容器对比扫射——新旧版本各起一套,逐路由比对状态码 + 正文长度;
  4. 错误日志先通——在业务功能之前先把错误采集做起来,刚通就能抓出一批真实告警;
  5. 升级路径实测——空库安装、旧库升级、重复执行(幂等)三种都要跑。

总结

PHP 8 迁移里最难的不是”改多少行”,而是三类问题:

  • @ 与静默降级——升级后从”少个提示”变成”整站崩”;
  • 全局初始化里的空对象——一处缺失,影响全站;
  • 入口差异——同一段逻辑在 Web 和 CLI 下行为不同,而且失败时可能静默。

一句话概括:**升级老系统,先把”错误可见性”建起来,再动业务代码。**错误看不见的时候,任何改动都像在黑暗里拆弹。