前言
从 7.1 升到 8.3,中间跨过了 7.2、7.3、7.4、8.0、8.1、8.2 六个版本。这不是"升一个小版本"的难度------PHP 8.0 是一次破坏性的大版本,移除了几十个函数、取消了若干历史写法;8.1 和 8.2 又收紧了类型与动态属性。所以升级时看到的报错会混在一起:既有"函数不存在"这种一眼能懂的致命错误,也有"某处行为悄悄变了"这种跑通了但结果不对的隐患。
把报错分类处理,事情就简单了。7.1 到 8.3 的问题基本落在三类:
- 致命错误(Fatal error):调用了已被移除的函数或语法,脚本直接中断;
- 异常/警告(Error / Warning) :以前宽松通过、现在按类型严格处理,例如未定义常量、
null传给内置函数、字符串参与算术; - 弃用提示(Deprecated):当前只是提示,但会在下一个大版本变成致命错误,属于必须排掉的技术债。
本文按这三类逐条给出对照与修法,并提供一个可以扫描整个项目、把已知高危写法列出来的 PHP 脚本。
一、先让所有错误都现形
升级的第一步不是改代码,而是让环境把所有问题喊出来 。很多遗留项目在生产上把 display_errors 关掉、error_reporting 调低,结果升级后"页面白了"却什么都看不到。
ini
; 升级验证环境(不要直接用于生产)
error_reporting = E_ALL
display_errors = On
log_errors = On
bash
# 全项目语法检查,先干掉解析层面的错误(花括号下标、关键字冲突)
find src -name '*.php' -print0 | xargs -0 -n1 php -l
php -l 只检查语法,不执行代码,能一次暴露所有解析错误,是升级的第一道筛子。之后再配合静态分析工具(例如 Rector、PHPCompatibility 规则集)跑一遍,把"能静态判定"的问题批量列出来,剩下的再靠单元测试和日志兜底。工具的版本支持范围以各自官方文档为准,升级前先用小范围目录试跑。
二、第一类:已被移除的函数与语法
这些是升级时必然报错的,属于"必须先改完"的部分。
| 被移除的东西 | 移除版本 | 报错形态 | 替代方案 |
|---|---|---|---|
create_function() |
8.0 | Call to undefined function |
用匿名函数 function () {} |
each() |
8.0 | Call to undefined function |
foreach,或手动 key()/current()/next() |
money_format() |
8.0 | Call to undefined function |
NumberFormatter(intl 扩展)或 number_format() |
$str{0} 花括号下标 |
8.0 | 语法错误 (php -l 就能查到) |
一律改成 $str[0] |
get_magic_quotes_gpc() 等 |
8.0 | Call to undefined function |
直接删除相关判断(PHP 7.0 起已无魔术引号) |
mysql_* 系列 |
7.0 | Call to undefined function |
PDO 或 mysqli |
preg_replace() 的 /e 修饰符 |
7.0 | 编译期报错 | preg_replace_callback() |
assert() 传字符串 |
8.0 | 不再执行其中的代码 | 改成 assert($条件) 或显式 throw |
implode($pieces, $glue) 旧参数序 |
7.4 弃用 / 8.0 报错 | TypeError |
参数顺序扳正:implode($glue, $pieces) |
parse_str() 只传一个参数 |
8.0 | ArgumentCountError |
必须传第二个数组参数 |
array_key_exists() 作用于对象 |
8.0 | TypeError |
用 property_exists() |
还有一类更隐蔽:关键字冲突 。match 在 8.0 成为关键字、enum 在 8.1 成为关键字、fn 在 7.4 成为关键字。项目里如果有 class Match、function enum()、$x->fn() 这类命名,升级后直接是语法错误。
php
<?php
// ❌ 升级到 PHP 8.0 后,含关键字命名的代码直接语法错误
class Match {} // 'match' 已是保留字
// ✅ 改名即可,注意同步所有引用处
class MatchResult {}
三、第二类:类型收紧带来的异常与警告
这一类的特点是"代码语法没变,行为变了"。
未定义常量从"告警 + 当字符串"变成致命错误。
php
<?php
// ❌ PHP 7.x:把未定义常量当成字符串 'APP_ENV',只给一条 notice
// PHP 8.0 起:Error: Undefined constant "APP_ENV"
if ($env === APP_ENV) { /* ... */ }
// ✅ 改成常量访问或字符串
if ($env === \App\Config::APP_ENV) { /* ... */ }
升级时如果在老代码里搜不到常量定义,这行代码以前一直在执行错误分支,现在终于报错了------报错本身是好事,但会让迁移过程突然多出一批"以前没人发现"的问题。
字符串参与算术运算。
php
<?php
// ❌ 8.0 起,非数字字符串参与算术会抛 TypeError
$total = "abc" + 1;
// ✅ 显式转换并校验
$raw = "abc";
$total = (is_numeric($raw) ? (int) $raw : 0) + 1;
构造函数的旧式命名不再生效。 PHP 7.x 里,与类同名的方法会被当作构造函数(老式写法);8.0 起这会触发弃用并被忽略,如果子类里因此没有调用父类构造,就可能出现"对象状态没初始化"的诡异 bug,而且不一定报错。
substr() 的返回类型变了。 7.x 上 substr('abc', 10) 返回 false,8.0 起返回空字符串 ''。依赖 === false 判断的代码会静默失效:
php
<?php
// ❌ 8.0 起恒为 false 分支,永远走不到
$part = substr($str, $offset, 5);
if ($part === false) { /* 旧逻辑 */ }
// ✅ 改成判空字符串,或直接判长度
if ($part === '' || $part === null) { /* ... */ }
null 传入内置函数参数。 PHP 8.1 起,向非 nullable 的内置函数参数传 null 会触发弃用提示,典型写法是 strlen($_GET['q'] ?? null)、htmlspecialchars($x) 而 $x 是 null。修法是在调用处 ?? '':
php
<?php
// ❌ PHP 8.1 起发弃用提示,8.4 起逐步收紧为类型错误
$len = mb_strlen($maybeNull);
// ✅ 提前归一
$len = mb_strlen($maybeNull ?? '');
动态属性。 8.2 起,在未声明属性的类上直接赋值会触发弃用提示:
php
<?php
// ❌ 8.2 起:Creation of dynamic property User::$nickname is deprecated
class User { public string $name = ''; }
$u = new User();
$u->nickname = 'x';
// ✅ 方案一:显式声明属性
class User2 {
public string $name = '';
public ?string $nickname = null;
}
// ✅ 方案二:确实需要动态属性时,用 __set/__get 或显式标注
#[\AllowDynamicProperties]
class LegacyRow {}
实现内置接口时不写返回类型。 8.1 起,继承 ArrayAccess、Iterator、Countable 等接口却未声明兼容的返回类型,会提示"应使用 #[\ReturnTypeWillChange] 属性或补全返回类型"。修法优先补全类型:
php
<?php
// ❌ 8.1 起弃用提示:Return type of ... should either be compatible ...
final class Bag implements ArrayAccess
{
public function offsetGet($offset) { return null; }
}
// ✅ 补上返回类型
final class Bag2 implements ArrayAccess
{
public function offsetExists(mixed $offset): bool { return false; }
public function offsetGet(mixed $offset): mixed { return null; }
public function offsetSet(mixed $offset, mixed $value): void {}
public function offsetUnset(mixed $offset): void {}
}
四、第三类:弃用提示,必须在升级里一并清掉
弃用提示不影响运行,但它们会在下一个大版本变成致命错误。写在日志里的每一条 Deprecated 都应该当成待办:
| 弃用项 | 弃用版本 | 替代 |
|---|---|---|
${var} 字符串插值 |
8.2 | {$var} 或 $var |
utf8_encode() / utf8_decode() |
8.2 | mb_convert_encoding() / iconv() |
strtolower() 等大小写函数改与 locale 无关 |
8.2(行为变更,非弃用) | 检查此前依赖系统 locale 的比较逻辑 |
Serializable 接口 |
8.1 | __serialize() / __unserialize() |
省略参数调用 get_class() |
8.3 | 显式传对象:get_class($obj) |
向内置函数传 null |
8.1 | 参数归一,?? '' |
${var} 这一条最容易在模板与 SQL 拼接里成片出现:
php
<?php
// ❌ 8.2 起弃用
$sql = "SELECT * FROM users WHERE name = '${name}'";
// ✅ 改成花括号包裹变量名
$sql = "SELECT * FROM users WHERE name = '{$name}'";
五、升级路线:先分步,再一口气
不建议从 7.1 直接跳到 8.3 发布上线。推荐路线:
text
7.1 → 7.4(清掉 7.x 内的弃用项)
→ 8.0(处理被移除的函数、关键字、类型收紧)
→ 8.1(处理 null 传参、接口返回类型)
→ 8.3(清弃用提示,用上 readonly/枚举等新能力)
每一站的验证手段固定为三件套:php -l 全量语法检查 + 静态分析工具扫描 + 单元测试跑通。没有测试的项目,至少要保证"关键业务流程手工回归一遍"。
六、代码实战:升级风险扫描脚本
在动手改代码之前,先跑一遍扫描,把高危写法连行号一起列出来。这个脚本只做正则匹配,误报难免,但作为第一轮摸底非常高效。
php
<?php
declare(strict_types=1);
// PHP 8.3 运行;被扫描的目标代码可以是任意版本
/**
* 扫描目录下所有 .php 文件里已知会在升级后出问题的写法
*/
function scanUpgradeRisks(string $dir): array
{
$patterns = [
'create_function() 已在 8.0 移除' => '/\bcreate_function\s*\(/',
'each() 已在 8.0 移除' => '/\beach\s*\(/',
'money_format() 已在 8.0 移除' => '/\bmoney_format\s*\(/',
'魔术引号函数已在 8.0 移除' => '/\bget_magic_quotes_(gpc|runtime)\s*\(/',
'mysql_* 已在 7.0 移除' => '/\bmysql_(connect|query|fetch_array|real_escape_string)\s*\(/',
'花括号字符串下标 $s{0} 已在 8.0 移除' => '/\$\w+\s*\{\s*[\d\x27"]/',
'${var} 插值已在 8.2 弃用' => '/\$\{[A-Za-z_]/',
'utf8_encode/decode 已在 8.2 弃用' => '/\butf8_(en|de)code\s*\(/',
'preg_replace /e 修饰符已移除' => '/preg_replace\s*\([^;]*\/[a-z]*e[a-z]*[\x27"]/',
'parse_str() 单参数写法' => '/\bparse_str\s*\(\s*[^,)]+\)/',
];
$hits = [];
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$lines = file($file->getPathname(), FILE_IGNORE_NEW_LINES);
if ($lines === false) {
continue;
}
foreach ($lines as $no => $line) {
foreach ($patterns as $label => $regex) {
if (preg_match($regex, $line) === 1) {
$hits[] = sprintf(
'%s:%d [%s] %s',
$file->getPathname(),
$no + 1,
$label,
trim($line)
);
}
}
}
}
return $hits;
}
$target = $argv[1] ?? __DIR__;
$hits = scanUpgradeRisks($target);
if ($hits === []) {
echo "未发现已知高危写法(仍需配合静态分析工具与测试复核)\n";
exit(0);
}
echo '共发现 ', count($hits), " 处待确认写法:\n";
echo implode(PHP_EOL, $hits), PHP_EOL;
运行:
bash
php scan_upgrade.php ./src
输出形态:
text
共发现 3 处待确认写法:
./src/legacy.php:12 [each() 已在 8.0 移除] while (list($k, $v) = each($arr)) {
./src/legacy.php:40 [花括号字符串下标 $s{0} 已在 8.0 移除] $first = $code{0};
./src/view.php:8 [${var} 插值已在 8.2 弃用] $sql = "SELECT id FROM posts WHERE title = '${title}'";
把它接进 CI,作为升级期间的门禁,能防止"改了一批、又漏了一批"。
常见坑点
1. 只盯着致命错误,忽略弃用提示
❌ 升级后把 error_reporting 调回低级别,"页面不白了"就算成功。 ✅ 升级期间保持 E_ALL,把每一条 Deprecated 都记成待办------它们在下一个大版本就会变成致命错误。
2. 在 7.1 上直接改到 8.3 再一次性发布
❌ 攒了几百处改动,一次上线,出问题不知道是哪一步引入的。 ✅ 按 7.4 → 8.0 → 8.1 → 8.3 分段升级,每段独立验证、独立发布。
3. 以为 == 的比较行为没变
❌ 0 == "abc" 在 7.x 上为 true(字符串被转成 0),8.0 起为 false。 ✅ 涉及数字与字符串比较的地方全部改成 ===,或先做显式类型转换。
4. 忘了第三方依赖也需要升级
❌ 业务代码改完了,某个老版本的 Composer 包在 8.3 上直接报错。 ✅ 升级前先跑 composer outdated,确认依赖链里有 8.3 兼容版本;composer why-not php 8.3 能直接指出是哪个包卡住了版本。
5. 用 @ 抑制升级暴露出来的警告
❌ 为了快速通过,在报错的地方加 @,把问题按下去。 ✅ 定位真实原因:@ 挡住的往往是"某处数据本不该为 null"这类根因。
6. 忽略了 OPcache 与配置差异
❌ 开发机(CLI + 无 OPcache)通过,生产(FPM + OPcache)行为不同。 ✅ 升级验证要在与生产一致的 SAPI 与 php.ini 下进行,改完配置记得重启 FPM。
7. 没检查保留字命名
❌ 类名、方法名里用了 match、enum、fn、readonly,升级后语法错误或行为异常。 ✅ 升级前全局搜索这些词作为标识符出现的位置,统一改名。
8. 一上来就用自动化工具全量重构
❌ 直接跑自动重构工具改完整个仓库,提交一个几千文件的大 diff。 ✅ 先在小范围试跑,人工审查输出,确认规则符合预期后再分批推进,每批单独提交便于回滚。
总结
| 报错类型 | 典型信息 | 处置方式 | 涉及版本 |
|---|---|---|---|
| 语法错误 | syntax error, unexpected '{' |
花括号下标改方括号,避开关键字命名 | 8.0 |
| 函数不存在 | Call to undefined function |
换成 preg_replace_callback、PDO、匿名函数等 |
7.0 / 8.0 |
| 类型错误 | TypeError / ArgumentCountError |
补类型转换、补齐函数参数 | 8.0 |
| 未定义常量 | Undefined constant |
定义为常量或类常量 | 8.0 |
| 行为静默变化 | 无明显报错但结果不对 | 检查 substr() 返回值、== 比较、构造函数 |
8.0 |
| 弃用提示 | Deprecated: ... |
逐条替换为官方推荐写法 | 8.1 / 8.2 / 8.3 |
升级 7.1 到 8.3 的难点从来不是"报错太多",而是报错的性质不同 :致命的那些反而好改,难的是那批不再报错、行为却变了的代码。把 error_reporting 开到最大、分段升级、每一段都有测试或手工回归兜底,再配上本文的扫描脚本做摸底,这条升级路径就会从"不可控的冒险"变成"可预期的例行工作"。