升级前先问一句:这个站真的需要升到 8.3 吗
PHP 7.4 在 2022 年底结束安全支持,很多个人站主的服务器还停在 7.4,直到某天插件报错、虚拟主机停止支持、或者 composer 装的依赖开始要求 php >= 8.1,才发现不得不升。但升级本身不是难点,难点在于那些「之前能跑、升级后静默出错」的地方——它们不会在启动时报错,而是在某个用户提交表单、某次价格计算时才暴露。
我在一个跑 Typecho 的站上做过完整的 7.4 到 8.3 升级,中间踩了六个坑。这篇文章按「准备—升级—验收」的顺序,把这六个坑和它们对应的检查手段写清楚。前提是同一个思路:先在另一个目录里装 8.3,线下跑通全部功能,再切线上。
第一阶段:清点依赖,别信 php -m 就够了
第一步是列出当前所有扩展,并逐个确认新版是否有对应的包:
php -v
php -m | sort
php -i | grep -E '^(Loaded Configuration|Scan this dir)'得到的扩展列表要和 apt-cache search php8.3- 的结果对照。Debian 12 的官方源里 PHP 是 8.2,要装 8.3 需要加 sury 源。这一步最容易踩的坑是扩展名变更:
php7.4-json在 8.0 起并入核心,包不存在了,但代码里ext-json依赖仍然满足,所以 composer 不会报错——这只说明不用再单独装,不需要改代码。php7.4-mcrypt从 7.2 起被移除到 PECL,如果老代码还在用,只能改用 openssl 实现或从 PECL 编。php7.4-imagick、php7.4-redis这类 PECL 扩展要注意版本,8.3 需要 imagick 3.7+ 和 phpredis 6.0+。老的 phpredis 5.x 在 8.3 上编译会失败。xmlrpc扩展在 PHP 8.0 起被移除到 PECL。这一点对 Typecho 站尤其重要——如果站上要用 XML-RPC 接口(很多客户端发布工具依赖它),必须装php8.3-xmlrpc,否则 endpoint 直接 500。
确认包名可用之后再动手。一个省时的技巧是先在测试目录里把扩展全装上:
apt-get install -y php8.3-fpm php8.3-mysql php8.3-curl php8.3-gd \
php8.3-mbstring php8.3-xml php8.3-zip php8.3-intl php8.3-bcmath \
php8.3-opcache php8.3-xmlrpc装上后 php8.3 -m 和 php -m 的输出要逐行比对,少了任何一个都要在升级前解决。
第二阶段:静态扫描——找出会「静默坏掉」的代码
PHP 8 有一批改动不会引发 fatal error,而是改变行为。这类问题最危险,因为测试可能都过。
坑一:字符串与数字比较语义变了。 PHP 8.0 里 0 == "abc" 从 true 变成 false。这个改动听起来很正向,但老代码里大量存在这种写法:
// 老代码常见写法
if ($user['role'] == 'admin') { ... }
// 如果 $user['role'] 是 null 或 0,PHP 7 下 null == 'admin' 是 false(OK)
// 但 if ($_GET['page'] == 0) 在 PHP 7 下,"abc" == 0 为 true,PHP 8 下为 false
// → 分页参数异常时的分支走向变了更常见的是 in_array($needle, $haystack) 没加第三个参数 true,在 PHP 8 下宽松比较变成严格字符串比较,in_array(0, ['a','b']) 从 true 变 false。用下面这条命令把所有候选位置列出来人工过一遍:
grep -rnE "in_array\([^,]+,[^,)]+\)" --include=*.php . | grep -v 'true)' | head -50坑二:未定义数组键从 Notice 升级为 Warning,且 @ 抑制符对某些错误无效。 PHP 8 里访问不存在的数组索引仍然是 Warning 而不是 Error,但如果错误处理器把 Warning 转成异常(框架里很常见),那就会变成 500。找出这类代码的关键是打开 error_reporting = E_ALL 跑一遍所有页面。
坑三:动态属性(Dynamic Properties)在 8.2 起 deprecated。 这是 8.3 升级里最常见的日志噪音。老代码给对象随手赋一个未声明的属性:
class Post { public $title; }
$p = new Post();
$p->views = 100; // 8.2+ 报 Deprecated: Creation of dynamic property它不会中断执行,但会在日志里刷满 deprecated 行,每天几百 MB 的 error_log 就是这样来的。修法有两种:给类加上 #[AllowDynamicProperties] 属性(最省事,适合老代码),或者在类里显式声明属性。用这条命令定位:
grep -rn "AllowDynamicProperties" --include=*.php . | wc -l
# 然后翻 error_log 找 "Creation of dynamic property" 的类名坑四:${var} 字符串插值语法在 8.2 起 deprecated。 "${name}" 要改成 "{$name}"。这个改动手工改很快,但前提是知道有多少处:
grep -rnE '"\$\{[a-zA-Z_]' --include=*.php . | head -30第三阶段:FPM 与 Nginx 的切换细节
PHP-FPM 的 socket 路径如果直接复用,切换时会互相打断。正确做法是让 8.3 用一个新的 pool 名和新的 socket,两个版本可以并存:
# /etc/php/8.3/fpm/pool.d/www.conf
[www]
listen = /run/php/php8.3-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660Nginx 上把 fastcgi_pass unix:/run/php/php7.4-fpm.sock; 改成 8.3 的路径,然后 nginx -t && nginx -s reload。切回 7.4 只要把这一行改回去,这是一个非常有价值的退路——比重新装 7.4 快得多。
这里有个容易忽略的连带问题:OPcache 的配置路径不一样。7.4 的 opcache 参数在 /etc/php/7.4/fpm/conf.d/10-opcache.ini,8.3 在对应目录下。如果之前手动调过 opcache.memory_consumption 或 opcache.max_accelerated_files,一定要把参数抄过去。忘了抄的表现是升级后 CPU 突然升高——因为 opcache 用了默认的 128MB,而站点文件数量超过默认的 10000 个上限后,缓存一直重刷。
php8.3 -i | grep -E 'opcache.(memory_consumption|max_accelerated_files|validate_timestamps)'第四阶段:验收清单——按功能而不是按页面测
升级完不能只看首页能不能打开。我用的清单分四组,每组都有明确的「怎么看」。
第一组:错误日志。 先清空日志再跑一遍全站功能,然后看:
tail -f /var/log/php8.3-fpm.log /var/log/nginx/error.log
grep -cE 'PHP (Warning|Deprecated|Fatal)' /var/log/php8.3-fpm.logDeprecated 数量不为 0 是正常的(老代码一定会有),但要看是不是同一处刷屏。Fatal 必须是 0。
第二组:数据库与字符集。 PHP 8.1 起 mysqli 和 PDO_MySQL 的默认字符集改成 utf8mb4,并且默认开启严格模式。这意味着原来能插入的截断字符串,现在会报 Incorrect string value 或 Data too long。测试方法是提交一条超长标题、一个含 emoji 的评论,看是否正常。如果报错,说明代码里原本依赖 MySQL 的宽松截断,这其实是数据缺陷,应该在应用层截断而不是关掉严格模式。
第三组:文件上传与图片处理。 GD 库在 8.x 上对某些格式的处理变严格了:一个损坏的 JPEG 在 7.4 下会返回 false 但流程继续,在 8.3 下可能抛出异常。测试上传一个正常图片、一个超限图片、一个改过扩展名的非图片文件,三个都要看返回值和页面提示。另外注意 upload_max_filesize 和 post_max_size 在新 pool 的 php.ini 里是不是还是默认的 2M/8M——这个值不会从 7.4 自动继承过来。
第四组:定时任务与命令行。 这一点最容易被漏。CLI 的 PHP 版本是独立于 FPM 的:php -v 在命令行下可能还是 7.4,因为 /usr/bin/php 这个符号链接指向 /etc/alternatives/php。crontab 里的脚本会用老版本跑,于是「网页正常但备份脚本报错」这种诡异现象就出现了。
update-alternatives --config php
# 或显式在 crontab 里写完整路径
# * * * * * /usr/bin/php8.3 /path/to/cron.php我更推荐后者:在 crontab 和 systemd unit 里写死 /usr/bin/php8.3,这样以后再有版本变化不会连带影响定时任务。
第五阶段:切换后的 24 小时
切换当天不要做其他变更,保持变量单一。要盯的指标:
- FPM 的
pm.status:慢请求数(slow requests)是否比升级前明显增加。如果增加了,多半是某个函数在新版上变慢了,常见的是preg_*家族的 PCRE2 升级导致的回溯行为变化; - MySQL 的
Threads_running:如果显著上升,可能是持久连接的行为变化; - error_log 的增长速率:用
wc -l两次相隔十分钟比对。
如果出现 5xx,第一件事是把 Nginx 的 fastcgi_pass 改回 7.4 的 socket 并 reload,这是 10 秒内能完成的回滚。7.4 的包先不要卸载,等稳定运行一周后再清理——这期间磁盘空间占用是多出来的唯一代价。
会话与 Cookie 的边界情况
升级后还有一类问题只在特定用户身上出现,排查起来很费时间,值得单独列出来。PHP 8 默认的 session.sid_length 和 session.sid_bits_per_character 与 7.4 不同,如果站上有自己实现 session 校验的代码(比如比对 session id 的长度或格式),升级后会把它判为非法而不断踢人下线。这类代码通常写在登录校验里,日志表现为「用户反复登录失败但密码是对的」。
同样要注意的是 setcookie() 的参数。从 PHP 7.3 起它支持数组形式的 options,而 8.x 上如果用位置参数传 SameSite,顺序必须是 path, domain, secure, httponly——一旦某一版把参数顺序写错,cookie 就设不上,登录态直接丢失。检查方式很简单:登录一次,在浏览器开发者工具里看 Set-Cookie 响应头是否带上了预期的属性。这个检查我现在会固定放在升级验证清单里,因为它是少数「代码完全没改,但行为变了」的典型。
最后:三个「不值得」的升级理由
做这类升级多了,会发现有些理由是无效的。「新版本性能更好」——PHP 8 确实比 7.4 快,但一个 IO 密集的 WordPress 站的瓶颈几乎总是在 MySQL 和网络,JIT 带来的提升在这些场景下不到 5%,为了 5% 去冒升级风险不划算。「插件要求新版本」——先看看是哪个插件,能不能换个替代品或者锁版本。「安全更新停了」——这一条是真正值得升级的理由,但也意味着可以用更长的时间做充分测试,而不是周末赶工。
真正该急切升级的场景只有一个:某个依赖爆出了在 7.4 上无法修复的漏洞,且你的站暴露在公网。除此之外,花两周时间做完整的线下验证,比用一个晚上赌运气要划算得多。