十年PHP5老系统升级PHP8,30多个坑和一份迁移手册
一套跑了十年的PHP5系统迁到PHP8,前后踩了30多个坑。本文按评估、环境、语法、错误模型、比较与类型、数据库、接口、灰度回滚的顺序整理成可执行的迁移手册,附关键代码对照,并说明升级后接口层重构与网站、小程序、APP并行改造的思路。
一套 2014 年前后上线、长期运行在 PHP 5.3 / 5.4 上的业务系统,十年间不断往上叠功能:官网、小程序接口、APP 后端、内部管理后台、支付回调、第三方对接全都在里面。支撑到期是绕不开的现实问题——PHP 5.6 的官方安全支持在 2018 年 12 月结束,PHP 7.4 也在 2022 年 11 月停止维护,继续留在老版本意味着没有安全补丁、Composer 依赖装不上、云厂商镜像越来越难找。
我们把整套系统迁到了 PHP 8.1 / 8.2,过程中踩了 30 多个坑。下面按 评估 → 环境 → 代码 → 灰度 → 收尾 的顺序整理,可以直接拿去对照执行。
一、迁移评估,先量清楚有多少代码要动
迁移拖成烂尾的项目,多数不是败在改代码,而是败在开工前不知道有多少地方要改。
1. 用工具先把雷扫出来
PHPCompatibility:PHP_CodeSniffer 的规则集,按目标版本扫描语法与函数级不兼容,产出可统计的报告。
Rector:做批量自动改写,移除函数、类型声明、命名空间这类机械替换能省掉大量人工。
Composer 检查:看依赖的平台约束、是否还有只支持 PHP5 的包,很多时候卡住的不是自己的代码而是第三方库。
扩展清单:
php -m在新旧环境各跑一次做差集,缺扩展会直接 Fatal error。
2. 把入口清单列全
至少要覆盖:网站前台与后台、小程序接口、APP 接口、支付与退款回调、定时任务(CLI)、队列消费、第三方推送、文件上传下载、导出任务。CLI 和 FPM 是两套运行环境,工作目录、include_path、内存限制、超时时间都不一样。定时任务最容易被漏掉,然后在上线第二天凌晨三点炸掉。
3. 目标版本别一步到位
建议先定在仍有安全维护的 8.1 或 8.2。直接跳到 8.4,等于把 8.x 的全量变更和 8.3、8.4 新增的废弃项叠在一起排查,定位难度翻倍。等系统稳定跑过一个版本周期,再考虑往上抬。
二、环境与扩展,先让它能跑起来
| PHP5 时代的依赖 | 现状 | 替换方向 |
|---|---|---|
| mysql 扩展 | 7.0 起移除 | mysqli 或 PDO_MYSQL |
| mcrypt 扩展 | 7.2 起移除 | openssl,padding 需自行对齐 |
| ereg / split 系列 | 7.0 起移除 | preg_match / preg_split |
| mbstring.func_overload | 8.0 起移除 | 显式调用 mb_ 系列函数 |
| 老版 redis / memcached 客户端 | 与新运行时 ABI 不兼容 | 升级扩展或改用 phpredis 新版 |
| gd、zip、soap、simplexml | 仍在,但默认编译项有变化 | 按实际调用补齐,别依赖默认 |
这一步的目标很朴素:让系统能启动、能出首页、能连上库。跑不起来之前谈重构没有意义。
三、30 多个坑,按类型分组
1)被移除的语法与函数(第一批必改)
| PHP5 时代写法 | PHP8 现状 | 替代方案 |
|---|---|---|
| mysql_query / mysql_fetch_array | 7.0 起移除 | mysqli 或 PDO |
| ereg / eregi / split / spliti | 7.0 起移除 | preg_match / preg_split |
| preg_replace 的 /e 修饰符 | 7.0 起移除 | preg_replace_callback |
| each() | 8.0 起移除 | foreach 或 current / next |
| create_function() | 8.0 起移除 | 匿名函数 |
| __autoload() | 8.0 起移除 | spl_autoload_register 或 Composer |
| 与类同名的方法当构造函数 | 8.0 起不再生效 | 改成 __construct |
| 静态方式调用非静态方法 | 8.0 起报 Error | 先实例化再调用 |
| define 第三个参数设大小写不敏感 | 8.0 起移除 | 常量名统一大写 |
| 用 $str{0} 花括号取字符 | 8.0 起移除 | 改用 $str[0] |
| implode($arr, ',') 参数反序 | 8.0 起移除 | implode(',', $arr) |
| get_magic_quotes_gpc、money_format、fgetss | 8.0 起移除 | 自行处理 / NumberFormatter / strip_tags |
| 直接使用未定义常量 | 8.0 起抛 Error(原来是 Warning) | 先 define 或改 const |
| 必选参数写在可选参数之后 | 8.0 起签名报错 | 调整参数顺序 |
另外提醒一个细节:PHP 8 开始支持 Attributes,注释里出现 #[ 开头的写法可能被解析成属性而不是注释,老代码里用 # 写的备注要顺手排查一遍。
2)错误模型变了,异常不再是异常
| 场景 | PHP8 行为 | 处理方式 |
|---|---|---|
| 内部函数参数个数不对 | 7.0 起抛 ArgumentCountError | 补全参数,别指望返回 null |
| 参数类型不符 | 抛 TypeError | 调用前显式转换 |
| 除数为零、取模为零 | DivisionByZeroError | 运算前判零 |
| 用 @ 抑制致命错误 | 8.0 起不再吞掉 | 改为 try / catch 或显式判断 |
| set_error_handler 捕获 Error | 捕获不到 | 配 set_exception_handler 或 catch Throwable |
| trim(null)、htmlspecialchars(null) | 8.1 起 Deprecated | 传参前统一转成空字符串 |
| htmlspecialchars 默认 flags | 8.1 起默认含 ENT_QUOTES | 显式写 flags,避免输出转义结果变化 |
老代码习惯了「函数报个 Warning 返回 false 继续往下走」,PHP8 直接抛异常,很多原本静默失败的链路会变成 500。建议在入口统一兜一层 Throwable,把错误落到日志而不是直接吐给用户。
3)比较与类型语义,最难查的一类
| 表达式 | PHP5 / 7 结果 | PHP8 结果 |
|---|---|---|
| 0 == 'abc' | true | false |
| in_array(0, ['a','b']) | true | false |
| 'abc' + 1(非数字字符串算术) | 等同 1 | 抛 TypeError |
| substr 越界取值 | 返回 false | 返回空字符串 |
| count(null) | 0 | 7.2 起 Warning,8.0 起 TypeError |
这类问题不会报错,只会让业务逻辑悄悄走错分支:订单状态判断、权限校验、金额比较都中过招。整改原则很明确——比较一律用 ===,数组查找加 true 参数,外部输入先做强转和校验。
// 旧写法:/e 修饰符在 PHP7 已移除
// $html = preg_replace('/\{(\w+)\}/e', '$vars[1]', $tpl);
// 新写法:回调里逻辑显式可控
$html = preg_replace_callback('/\{(\w+)\}/', function ($m) use ($vars) {
return $vars[$m[1]] ?? '';
}, $tpl);
// 比较语义:PHP8 中 0 == 'ok' 为 false,老代码的分支会悄悄改变
if ((string) $status === 'ok') {
// 明确类型,不依赖隐式转换
}4)字符串、数组与正则的细节
嵌套三元不加括号,7.4 起废弃、8.0 起直接报错,改成 if 更省事。
array_key_exists 传对象,7.4 起废弃,对象属性判断用 property_exists。
strftime / gmstrftime 在 8.1 起废弃,时间格式化统一收口到 date 或 DateTime。
libxml_disable_entity_loader 在 8.0 起废弃(默认已安全),老代码里那行调用删掉即可。
命名参数是 8.0 的新能力,内部函数参数改过名,如果代码里已经用了命名参数要逐个核对。
金额、比例这类浮点转字符串,建议统一走 sprintf 或 number_format,别依赖隐式转换。
5)数据库与编码
| 问题点 | 现象 | 处理建议 |
|---|---|---|
| PDO 模拟预处理 | limit 绑定参数变成字符串导致语法错误 | 关掉 ATTR_EMULATE_PREPARES,limit 用 PDO::PARAM_INT |
| ONLY_FULL_GROUP_BY | 老 SQL 直接报错 | 改写 group by 字段,不要一味放宽 sql_mode |
| 零日期 0000-00-00 | 严格模式下写入失败 | 统一改 NULL 或合法日期 |
| 字符集 | emoji、生僻字截断或乱码 | 建库、建表、连接层统一 utf8mb4 |
| 浮点序列化精度 | 序列化后数值对不上 | serialize_precision 设为 -1,金额用整数分或 decimal |
// 旧写法:函数在 PHP7 已移除,PHP8 下直接 Fatal error
// $res = mysql_query('SELECT * FROM orders WHERE id = ' . $id);
// 新写法:预处理绑定,顺带堵住注入
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$stmt = $pdo->prepare('SELECT * FROM orders WHERE id = ?');
$stmt->execute([(int) $id]);
$order = $stmt->fetch();6)加密与外部接口,最容易上线后才发现
openssl 的 padding:openssl_encrypt 默认 padding 为 0,需要显式加 OPENSSL_RAW_DATA 或 OPENSSL_ZERO_PADDING。从 mcrypt 迁过来时要手动补 PKCS7,否则密文和前端的解密结果对不上。
curl 证书校验:CURLOPT_SSL_VERIFYPEER 默认开启,以前为了跑通把它关掉的老代码,现在要靠正确配置 CA 证书解决。
json_encode 失败返回 false:脏数据、非 UTF-8 字符都会触发,接口签名和回调验签最容易在这里出错,建议加 JSON_THROW_ON_ERROR 或显式检查 json_last_error。
小程序与 APP 接口签名:签名依赖 JSON 序列化顺序和转义方式,升级前后要固定 JSON_UNESCAPED_UNICODE 和字段顺序,否则同一份数据算出来的签名不同。
XML 回调:支付类回调多用 XML,simplexml 扩展要显式安装,入口处补一层结构校验。
7)框架与第三方库,可能是最大的工作量
ThinkPHP 3.2、CodeIgniter 2、Yii1 这类老框架在 PHP8 下问题集中:大量 preg_replace /e、mysql_* 调用、动态属性赋值。常用库也有坑:PHPExcel 需要换 PhpSpreadsheet,Smarty 2 要升到新版本,老版 PHPMailer、TCPDF 基本都要跟着升。建议先做依赖矩阵,评估「升级依赖」和「局部重写」哪个更划算。
8)运行时配置
display_errors 关闭、log_errors 打开,错误写文件或落日志平台。
E_ALL 的语义在新版本有变化,别沿用十年前的 error_reporting 数值。
max_input_vars 默认 1000,大表单、批量提交、复杂筛选条件很容易超限导致字段丢失。
opcache 与 realpath_cache_ttl 要按部署方式调,代码发布后行为不一致多半出在这里。
date.timezone 必须显式配置,CLI 与 FPM 要一致。
9)动态属性(8.2 起)
// PHP 8.2 起,给未声明的属性赋值会触发 Deprecated
$order = new Order();
$order->extra = 1; // Deprecated: Creation of dynamic property
// 过渡方案:确实需要动态属性时显式声明
#[AllowDynamicProperties]
class Order {}
// 更稳的做法:字段声明出来,可读性和静态检查都更好
class Order2 { public int $extra = 0; }四、灰度与回滚,怎么保证不出事故
环境隔离:新版本先跑在独立容器或独立域名上,与生产共用一套数据库副本,不改数据结构。
自动化回归:把核心链路做成接口冒烟脚本——登录、下单、支付回调、退款、消息推送、定时任务,每次改动跑一遍,比人工点页面可靠。
灰度分流:按 Nginx upstream 权重或用户白名单切流量,先内部账号,再 1% 真实用户,观察 24 小时。
可回滚:代码双版本共存,配置开关控制走哪套运行时;涉及数据写入的改造先做兼容双写,别做不可逆变更。
看指标:error_log 新增条目、5xx 比例、接口 P95 耗时、慢查询数量、任务失败率。其中错误日志里的 Deprecated 要当成待办清单,不要当成噪音。
五、迁完之后,老系统的下一步
升级到 PHP8 只是把地基补上,不是终点。这类十年老系统通常同时扛着官网、小程序接口、APP 后端和一堆第三方对接,业务逻辑和页面渲染混在一起,改一处牵动全身。比较划算的做法是借这次迁移把接口层抽出来:统一入口、统一签名、统一日志和错误码,前端该重做的重做,小程序和 APP 走同一套 API,以后再升级运行时,影响面就从「整个系统」收缩到「一层接口」。
尊云科技(ZUNYUN.CC)在这类项目上的常规路径是:先做老系统代码体检和兼容性评估,输出改造清单与排期;再按模块渐进式升级,全程保留可回滚;新增业务需求可以和升级并行推进,网站建设、APP开发、小程序开发、API 接口对接由同一套后端支撑,二次开发和老系统局部重构也在服务范围内,避免出现「升级一次、业务停摆三个月」的局面。
六、需要有人一起看代码的话
如果手上也有一套跑了很多年的 PHP5 系统,不确定该升级还是重写,可以先做一次代码体检:把扩展依赖、被移除函数、框架版本、入口清单跑一遍,通常两三天能出一份可执行的改造范围和工作量评估。需要的话可以联系尊云科技,我们协助做兼容性排查、升级方案评估,或者直接接手接口层重构与二次开发,把风险和工期摊开来算清楚再动手。