PHP 8 下维护老系统:静默失效、致命错误与编码约束
接手一套跑了十多年的 PHP 系统,第一件事往往不是加功能,而是”让它在新版 PHP 上别崩”。这类迁移的坑特别有迷惑性:有些是直接白屏,有些是页面看起来正常、数据悄悄错了,还有些是”只有某一个入口才会踩”。
本文汇总四次移植:一套禅道内核(PHP 5/7 时代的代码迁到 PHP 8.5)、2003 年的 Discuz! 2.5 SP1(全程 GBK 编码,125 个 PHP 文件)、跑了多年的 Discuz 4.1(移植 + 安全加固)、以及一套老建站系统 chanzhiEPS(PHP 8.4+ 全量移植 + 自动发布)。最后附一个 Delphi 7 老客户端上的同类问题——技术栈差二十年,踩的坑却高度同构。
一、@ 挡不住致命错误
现象:某个页面突然 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 | if (!is_array($cache['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 | $str{0} // PHP 8 已移除,改 $str[0] |
前者会直接报语法错误(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 白名单。它有三个反直觉点,每一个都能让人查半天:
- 未登记的 cookie 会被静默丢弃,不报错。表现为”某个开关永远不生效”。
- 规则键必须用小写方法名。框架取到的方法名是
exportcase(全小写),写成驼峰exportCase匹配不上。 - 父级对象必须先创建。直接写子级规则,会触发整站致命错误——我们中途把测试站弄挂过一次。
第三点尤其要注意:配置写错不是”这条规则不生效”,而是”整站打不开”。
六、把对象当字符串 printf
现象:访客访问某些页面,返回的不是”无权限”提示,而是带完整文件路径的 Fatal 错误页。
根因:权限提示模板里有一行把”语言组对象”当成”方法的中文名”去做格式化输出。正常情况下取到的是字符串;一旦某个键名与同模块的方法名撞车,取到的就是对象,printf 直接崩。
风险:错误页会暴露服务器绝对路径——这既是可用性问题,也是信息泄露。
修法:取值时加类型守卫(is_string()),并为这类”错误处理路径本身出错”的场景补一条兜底:错误处理代码必须假设自己拿到的都是脏数据。
七、CLI 入口和 Web 入口不是一回事
现象:文档里写着的升级命令,执行完退出码 0、没有任何输出,看起来像成功,实际什么都没做。
根因:那段逻辑依赖一个只在 Web 入口赋值的变量(当前安装版本号)。CLI 入口下该变量为空,代码判定”来源版本非法”,于是拒绝执行——但它既没抛错也没打印,直接静默返回。
修法:让 CLI 路径自己从配置里读版本,而不是复用 Web 入口的全局状态。
教训:“退出码 0”不等于”做成了”。凡文档里的命令,验收标准必须是”有可观测的副作用”,比如数据库版本号变了、文件时间戳变了。
八、视图移植了、控制层没跟上
做界面层移植时很容易只搬模板(view/),因为”看起来变了”。但模板里用到的变量是控制层赋的,两边版本一旦错位,就会出现”页面结构对了、内容是空的”。
典型表现是页面渲染只剩骨架,或者某个区块整块消失。这类问题的排查顺序应该是:
- 先看渲染出来的 HTML 有没有内容(别只看 HTTP 200);
- 再看模板要的变量名,控制层到底赋了没有;
- 最后才是 CSS/JS。
实测数据:一次移植后做双容器对比扫射(旧版 vs 新版逐路由抓正文长度),177 个路由里能定位出”内容缩水 90% 以上”的空壳页——这类页面的 URL、状态码、布局断言全都正常,只有比对正文长度才抓得到。
九、可复用的排查顺序
维护老系统时,下面这套顺序能覆盖绝大多数问题:
php -l全量——先消灭语法级问题,几十秒就能跑完上千文件;- 静态扫描已移除 API——把”必然崩”的点一次性清掉;
- 双容器对比扫射——新旧版本各起一套,逐路由比对状态码 + 正文长度;
- 错误日志先通——在业务功能之前先把错误采集做起来,刚通就能抓出一批真实告警;
- 升级路径实测——空库安装、旧库升级、重复执行(幂等)三种都要跑。
十、几百处裸下标:工具能改,但工具本身要验
Discuz! 2.5 的症状更直接:每个 GET 请求都致命,首页都起不来。
1 | Fatal error: Uncaught TypeError: extract(): Argument #1 ($array) |
根因在引导层:一个转义函数对空数组返回了 false,调用方拿去做 extract()。
但这个库真正的硬骨头在别处:全站 841 处裸数组键,分布在 46 个文件里,其中 572 处藏在双引号字符串内。$var[key] 在 PHP 5 里被宽容地当成字符串键,PHP 8 把它当未定义常量,直接报错。
这里千万别用正则批量替换。 裸键和字符串字面量长得一样,正则会误伤注释和用户文本。正确做法是用 PHP 自己的 tokenizer 写一个只认”数组下标位置”的改写器。
然后我多踩了一步:第一版改完 lint 干净,第二版想更精确,结果反而改坏了 9 个文件——因为 PHP 8 的简单插值只支持一个下标,而带引号的下标在简单插值里本身就构成解析错误。最后只能按第一版重建。
同类问题在 Discuz 4.1 里换了张脸:复杂插值 {$arr[key]} 的裸键在 PHP 8 被当常量,报 Undefined constant "fid"。它和简单插值 $arr[key] 语义不同——后者才是字符串键,两种写法只差一对花括号。
两个连带教训:静态扫描器最初把”双引号字符串内的下标”整个排除了(因为简单插值确实安全),漏掉了复杂插值这一类,这个问题是靠端到端遍历页面抓出来的,不是扫出来的;批量加引号时用的是”匹配最内层键”的正则,嵌套的 {$GLOBALS[lang]['page']} 外层会漏——扫描器其实报了这处,被我当噪音略过了。
十一、extract() 的两难,和”看起来像 bug 的有意代码”
同一个 extract(),两处要相反的参数。
post.php 用 EXTR_SKIP 是对的——提取的是新变量,不覆盖已有值。但登录那段必须用 EXTR_OVERWRITE:因为引导层里已经存在 $adminid、$groupid、$discuz_uid 这些全局变量,用 EXTR_SKIP 的话新值根本进不去。症状是”登录不报错,但就是不生效”。
而且这段的致命错误只在登录失败时触发(fetch_array 返回 null)——正常登录流程测不出来,必须专门测错误路径。
另一个更值得记的:缓存模块里有个 array_del(),读起来像是坏的——它对数组调 trim($f),条件恒为假,从来没有删除过任何键。但站里的欢迎短消息、版规恰恰依赖这些键留在缓存中。这个”空实现”是功能的一部分。
修法只消除 TypeError、行为原样保留,并在代码里写清原因——否则下一个人会”好心”把它修成真的删除。判断标准:如果一个”bug”已经存在二十年而功能正常,先假设它是特性,再去找证据。
十二、一个赋值,让所有数据库错误变成白屏
这是这个月最贵的一条。
Discuz 4.1 的数据库层里有一行看着很正常的代码:
1 | $this->conn->report_mode = MYSQLI_REPORT_OFF; |
意图是关掉 mysqli 的异常模式,让错误走框架自己的优雅提示。问题是,report_mode 根本不是 mysqli 的属性——这行只是给对象动态加了个属性(PHP 8.2 起还会给一条弃用警告),行为上是彻头彻尾的空操作。
而 PHP 8.1 之后的 mysqli 默认就是抛异常模式,且 @ 抑制无效。于是数据库连不上、SQL 出错,全都变成未捕获异常 → 白屏,本该显示”数据库暂时不可用”的页面变成一张没有任何信息的白屏。
正确写法只有一种:
1 | mysqli_report(MYSQLI_REPORT_OFF); |
这条坑最贵的地方在于它有”假证据”:生产目录的错误日志里堆了 4186 条记录,看起来像”数据库连接一直有问题”。实际上那些全是白屏被记录下来的结果,不是原因。修完之后数字立刻归零。
十三、GBK 是全程的约束
老系统用 GBK,这个约束会出现在四个完全不同的地方。
写回文件会被截断。 用 utf-8 或 latin-1 去读写含中文的 PHP 文件,会出现编码失败甚至文件被截断。正确姿势是从 git 基线恢复,用 GBK 往返(先解码再编码)重做。
数据库连接要显式设字符集。 重写数据库层时保留 SET NAMES gbk,否则取出来的中文全是问号。
界面全是方块。 查了两层:第一层是响应头写 charset=UTF-8 而正文是 GBK,浏览器按头解码全变替换字符;第二层更隐蔽——初始化数据库时把安装 SQL 按 UTF-8 写进了 GBK 库,于是”游客””管理员”这些初始数据本身就是错的字节。用 GBK 字节重新导入才干净。
GBK 的尾字节可能是反斜杠。 某几个汉字的 GBK 编码第二个字节是 0x5C(\),旧的手写逐字节 JSON 转义会把它当转义符,**直接破坏消息、产出非法 JSON**。修法是按 ByteType 识别双字节字符、整字跳过,而不是逐字节处理。
十四、跑得起来但会出事的地方
char(32) 装不下 bcrypt。 老库密码列是 md5 的长度,升级后 60 个字符会被静默截断。症状极迷惑:第一次设置密码能登录成功,第二次就失败。修法是列宽改 varchar(255),新密码用 password_hash(),历史 md5 在登录成功后自动升级。
TYPE=MyISAM 在现代 MySQL 是语法错误。 老安装脚本里 58 处 TYPE=MyISAM + 1 处 Type=MyISAM,还有 TYPE=Heap。要改成 ENGINE=InnoDB / ENGINE=MEMORY,同时把编码从 GBK 转 UTF-8、文件头加 SET SESSION sql_mode=''(否则零日期会报错)。转换后有三处”异常”全是误报:TYPE= 残留在自己的注释里、第 60 处 ENGINE= 是数据内容、中文行数为 0 是因为安装脚本本身就是空库结构。
短标签保护头会失效。 备份文件开头常写 <?exit();?> 防止被直接下载读取,PHP 8 默认关闭 short_open_tag 后这句直接失效。但同一项目里 <?PHP exit(...)?> 仍然有效——大写 PHP 不是短标签,加固时不能一刀切。
行尾符会污染 diff。 编辑工具把混合行尾统一成 CRLF,viewthread.php 显示改了 100 行、实际内容只改 1 行(真实 53 行 vs 噪音 88 行)。修法是按内容匹配还原原行尾。
字符集别从 dump 头部推断。 dump 开头的 SET NAMES utf8 是 mysqldump 客户端的连接字符集,不代表表字符集——要看 59 张表的 DEFAULT CHARSET。顺带:用 head -c 截断 dump 看到的 '� 是多字节字符被切断的伪影,不是坏数据。
十五、容器与运行环境的几个细节
rootless podman 里 user=1000 反而不对。 容器内只有 root 映射到宿主用户,设 user=1000 会落到 subuid、写不了文件;改成 user=0 又会让 fpm 拒绝以 root 跑池进程。最终用 userns_mode: keep-id,容器内 uid 1000 = 宿主用户。
php-fpm 的工作目录不是站点根。 所有相对路径写缓存都会失败,要给池配置 chdir。
nginx 会缓存 php 容器的旧 IP。 改完代码不生效,重启 php 之后必须一起重启 nginx。
顺手补掉的安全缺口:install.php 的 nginx 配置只写了 allow/deny、漏了 fastcgi_pass,PHP 被当静态文件返回、直接泄露源码;后台首页会自动删除 install.php(Discuz 自带的安全特性),我在冒烟测试时把它删了两次才发现。发布前还做了凭据清洗——config.inc.php 虽已从最新提交删除,但数据库密码仍在 git 历史里,用 git filter-repo 重写历史、git log -p --all 复扫确认零命中,并轮换口令。
十六、附:Delphi 7 老客户端上的同类问题
同一时期还复活了一个 Delphi 7 写的、给 Windows 95 用的 AI 聊天客户端。技术栈完全不同,但问题类型高度相似。
源码编码是硬约束:GBK (CP936) + CRLF + 无 BOM,窗体字体「宋体」9pt、Font.Charset = 134。这些常量我都是从 Delphi 7 自己的源码 Rtl/Win/Windows.pas 里确认的——老工具链的定义,宁可去查源码,别猜。
TCanvas 会偷偷重置 BkMode。 切换字体后 BkMode 被重置为不透明,DrawText 就用当前画刷(Logo 绘制遗留的白色)填充文字背景,形成一块白底。修法是封装统一绘制函数,每次绘制前强制 SetBkMode(TRANSPARENT),全文件 14 处都换过去。
Delphi 7 不允许在方法的 var 块之后定义嵌套过程,报 Identifier expected but 'PROCEDURE' found,还会级联出一堆看似无关的错误。
.res 头结构错了会连累 IDE 崩溃。 生成的图标资源报 “Invalid resource file”——.res 头偏移 4 应为 0x20、偏移 8/12 应为 0xFFFF,而我生成的是全 0。改对后又崩了一次(coreide70.bpl AV):原因是加了 48×48 尺寸,而 Delphi 7 时代的图标只含 32×32。改回 16+32 两个尺寸才稳定。
没有编译器时的验证方式:所有 API 签名逐条回查 Delphi 7 源码原文;编码用”GBK 可解码、UTF-8 不可解码”双向校验确认。编译只能交给装了 Delphi 7 的虚拟机。
十七、附二:chanzhiEPS 的 PHP 8.4+ 移植
同一时期还把一个老建站/电商系统从 PHP 5 时代搬到 PHP 8.4+。体量和写法都和 Discuz 不同,但踩的坑高度重合。
先抛开旧文档,从代码事实重写项目说明。 旧文档里有 8 处事实是错的:模块数是 44 不是 45、前后台权限配置在 system/config/front.php 与 admin.php 并按 RUN_MODE 加载、前台视图在 www/template/、主题样式在 www/theme/<template>/<theme>/style.less 由 ui 模块调 lessc 编译、扩展只有模块内 ext/、仓库既没有 Composer autoload 也没有自动化测试。改老系统之前先花半天确认文档说的对不对,比直接动手省时间。
又是 char(32) 装不下 bcrypt。 安装时管理员创建失败,根因还是密码列按 md5 长度设计、而代码已改用 bcrypt(60 字符)。改 varchar(255) 并补一条 upgrade5.2.1.sql——这个月已经是第二次踩同一个坑(第一次在 Discuz 2.5)。
error 控制器与 PHP 内置 Error 类冲突。 类名直接不可用,只能重命名成 errorControl,同时让路由支持 <module>Control 的类名约定。
PHP 8.4/8.5 的弃用项一串。 编译期扫描出 39 处:E_STRICT 常量、trigger_error(E_USER_ERROR)、exit(header(...))、反引号运算符、分号形式的 case、隐式 nullable、可选参数位于必选参数之前、动态属性、preg_match 传 null 参数。另外移动端模板里未加括号的嵌套三元表达式在 PHP 8 起是致命错误——这个只有 php -l 扫得到,跑起来才报就晚了。
升级流程里 lessc 报错。 主题编译时把颜色类型标记当数字相乘,PHP 8 直接 TypeError;另有浮点取模的隐式转换。两处修完,5.2_php8 → 5.2.1 → 5.2.2 的升级链路才跑通。
CLI 工具全挂。 xrcli 还引用着 E_STRICT、且缺 DS 常量;check.php 的 config 存根触发动态属性弃用。两个都要把退出码修回 0,否则自动化链路会一直以为失败。
主题调试的几轮反复值得记:首页那圈紫色其实是轮播 .carousel-caption 的背景、不是主题色溢出;头部左右被裁切是没按 1170px 容器居中;改完 CSS 浏览器还显示旧样式,需要重新编译主题并更新 customVersion 强制刷新。
验证结果:PHP 8.5.12 下 1208 个 PHP 文件 php -l 全过(0 语法错误、0 编译期弃用),含演示数据全新安装成功、管理员密码以 bcrypt 完整存储,前后台主要页面 200 且无 Deprecated/Warning。顺手删掉了内置的在线主题市场(入口、页面、API、菜单、权限、语言一并清),按主站视觉规范新建了一套紫色模板与主题。
一个环境前提:本机没有 php 命令,所有 php -l / check.php / xrcli 的验证都得在 podman 容器里跑——所以第一阶段的文档只能靠读脚本内部的依赖逻辑来确认用法,不能凭”文档这么写”。
总结
四次移植加起来,最难的不是”改多少行”,而是三类问题:
- 静默降级——
@从”少个提示”变成”整站崩”,report_mode那种空操作让所有数据库错误变白屏; - 全局初始化与入口差异——一处空对象影响全站,同一段逻辑在 Web 和 CLI 下行为不同、失败时还可能静默;
- 编码与环境前提——GBK、列宽、
sql_mode、短标签、bcrypt 长度,这些都不会让php -l报错,却能在运行时毁掉整站。
可以压成五条做法:
- 能交给工具改的别手改,但工具本身要验——我的第二版扫描器就改坏了 9 个文件;
- 看起来像 bug 的地方先假设它是功能,尤其那个”从不删除”的函数;
- 编码约束要贯穿全程:写文件、连数据库、发响应头,任何一环按 UTF-8 处理都会出现方块字;
- “没有报错”和”没有问题”是两件事——那 4186 条日志在修复前看起来像数据库故障,实际是白屏的副产品;
- 先把”错误可见性”建起来,再动业务代码。错误看不见的时候,任何改动都像在黑暗里拆弹;
- 同一个坑会在不同项目重复出现——
char(32)装不下 bcrypt 这个月踩了两次,值得单独记进检查清单。
最后一句:环境要恢复到”未安装”状态再交付,让别人自己跑一遍安装向导。那才是真正的验收。