前言
多数团队升到 PHP 8.3 并不是主动决策,而是被动的:新买的服务器默认装了 8.3,Docker 官方镜像拉下来的就是最新的 8.3.x,或者运维为了统一环境把开发机和生产机一起换了版本。结果就是一批 2018 年前后写的老项目,在 8.1、8.2 上跑得好好的,一上 8.3 就开始在日志里刷Deprecated:消息,某些框架的测试套件因为把废弃警告当异常直接变红。
更麻烦的是认知错位。网上不少文章把 8.4 的特性写成 8.3 的,比如把array_find()、array_find_key()、array_any()、array_all()说成是 8.3 新增。这些函数其实是PHP 8.4引入的,在 8.3 上调用会直接Call to undefined function致命错误。如果照着这类文章改代码,线上会立刻 500。同理,clone的对象修改语法、管道操作符|>、array_first()/array_last()都是PHP 8.5的东西,也不能算在 8.3 头上。
本文先把 PHP 8.3 真正带来的变更列清楚,再说明哪些变更会直接打到旧项目身上,最后给出一个可以运行的扫描脚本,帮你在升级前把项目里已知的废弃用法找出来。
一、PHP 8.3 真正新增了什么
下面这张表只列 8.3 的事实,容易混淆的相邻版本单独标注。
| 特性 | 真实版本 | 说明 | 对旧项目的攻击面 |
|---|
| 类型化类常量(typed class constants) | 8.3 | 类常量可以写类型,如const int MAX = 10 | 子类覆盖常量时类型必须兼容 |
#[\Override]属性 | 8.3 | 标在方法上,若父类没有同名方法则编译报错 | 纯新增,不改旧代码就不会触发 |
json_validate() | 8.3 | 只校验 JSON 合法性,不构造 PHP 值 | 可替换「decode 后判断 null」的老写法 |
动态获取类常量Class::{$name} | 8.3 | 常量名可以用变量 | 与constant()并存,不影响旧代码 |
mb_str_pad() | 8.3 | 多字节版的str_pad | 纯新增 |
Randomizer::getBytesFromString()、getFloat()、nextFloat() | 8.3 | Random 扩展的方法补充 | 注意Random\Randomizer类本身是8.2引入的 |
readonly属性可在__clone中重新赋值 | 8.3 | 深拷贝时终于能改只读属性 | readonly属性本身是8.1,readonly类是8.2 |
匿名类可以声明为readonly | 8.3 | 语法放宽 | 无 |
日期时间异常更细分(DateMalformedStringException等) | 8.3 | 原来统一抛Exception的地方改成专门异常类 | 只catch (Exception)的代码仍能兜住 |
array_find/array_any/array_all | 8.4 | 常被误传为 8.3 | 在 8.3 上调用直接致命错误 |
clone with、管道操作符 ` | >、array_first/array_last` | 8.5 | 更晚的版本 |
判断一条「新特性」能不能用,最稳的办法不是查博客,而是直接问运行环境:php -r "var_dump(function_exists('array_find'));"。函数存在与否是事实,写不写进文章是观点。
二、真正会打到旧项目身上的三类变更
第一类:废弃(Deprecated)警告。8.3 里有几个高频用法被标记废弃:不传参数调用get_class()和get_parent_class();只传一个参数调用ReflectionProperty::setValue()或getValue()来读写非静态属性。这些在老项目里非常常见,因为get_class()无参写法曾经是被推荐的「取当前类名」写法。默认error_reporting下它们只是日志噪音,但任何把E_DEPRECATED转成异常的错误处理器(Laravel、Symfony 的测试环境默认会这么做)都会直接抛错。
第二类:类型化类常量带来的继承冲突。类型化常量本身是纯新增语法,但它改变了一件事:如果父类写了const int MAX = 10,子类只能覆盖成int类型的值。旧项目里「父类定义字符串版本号、子类改成10覆盖」这种写法,升级后会在类加载时直接报Cannot inherit previously-inherited or override constant类的致命错误,白屏且没有堆栈。
第三类:行为细化导致的异常类型变化。日期时间相关的失败场景从抛通用的Exception变成了抛更具体的异常类。如果旧代码里靠catch (\Exception $e)兜底,问题不大;但如果用了catch (\Error $e)或者依赖异常类的名字做分支判断,行为就会变。
还有一类不算 8.3 引入、但会在这个阶段集中爆发的问题:动态属性废弃(8.2起)、隐式可空参数废弃(8.4起)。升级到 8.3 时你会看到 8.2 遗留的告警,升级到 8.4 时会看到更多。把账记清楚,才不会误判成「8.3 变坏了」。
三、升级前的检查顺序
- 确认运行时事实:
php -v、php -m,以及php -r "echo PHP_VERSION;",三处版本必须一致,避免 CLI 换了、FPM 没换这种错位。 - 锁定依赖的可安装版本:
composer check-platform-reqs会基于当前 PHP 版本检查composer.json的require与vendor的实际安装情况;vendor/composer/platform_check.php是 Composer 自动生成的运行时兜底检查,它会在入口处直接拦住版本不符的环境。 - 静态扫描废弃用法:跑下面的脚本,把可疑点列出来。
- 跑测试并临时提升报告级别:在测试环境把
error_reporting设为E_ALL,把E_DEPRECATED也打出来,让告警在升级前就暴露。 - 最后才是动生产:升级顺序永远是「开发机 → 预发 → 生产」,且每一步都清一次 opcache。
四、可运行的扫描脚本
下面这个脚本需要PHP 8.0 或更高(用到str_contains和构造器属性提升)。它递归扫描指定目录下的.php文件,用词法分析器token_get_all()找出三类高风险的 8.3 废弃用法,输出文件名与行号。
<?php
declare(strict_types=1);
/**
* 用法: php scan83.php /path/to/project
* 扫描 PHP 8.3 中已废弃的常见写法。
*/
final class DeprecationScanner
{
/** @var array 命中记录,键为「文件路径:行号」 */
private array $hits = [];
public function __construct(private string $root)
{
}
public function run(): void
{
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($this->root, FilesystemIterator::SKIP_DOTS)
);
foreach ($it as $file) {
if (!$file->isFile() || $file->getExtension() !== 'php') {
continue;
}
$this->scanFile($file->getPathname());
}
if ($this->hits === []) {
echo "未发现明显废弃用法\n";
return;
}
foreach ($this->hits as $line => $count) {
echo $line, "\n";
}
echo "共 ", count($this->hits), " 处,请逐条确认\n";
}
private function scanFile(string $path): void
{
$code = file_get_contents($path);
if ($code === false) {
return;
}
// 跳过 vendor 目录,第三方库的告警由 composer 统一处理
if (str_contains(str_replace('\\', '/', $path), '/vendor/')) {
return;
}
foreach (token_get_all($code) as $token) {
if (!is_array($token)) {
continue;
}
[$id, $text, $line] = $token;
if ($id === T_STRING && in_array(strtolower($text), ['get_class', 'get_parent_class'], true)) {
$this->hits[$path . ':' . $line] = 1;
}
}
}
}
$root = $argv[1] ?? __DIR__;
(new DeprecationScanner($root))->run();再配一个自检脚本,用来在升级前确认目标环境是否真的具备 8.3 的能力,避免照着错误文章写出跑不起来的代码:
<?php
declare(strict_types=1);
$features = [
'json_validate (8.3)' => function_exists('json_validate'),
'mb_str_pad (8.3)' => function_exists('mb_str_pad'),
'Override 属性 (8.3)' => class_exists('Override'), // 全局属性类
'日期异常类 (8.3)' => class_exists('DateMalformedStringException'),
'Randomizer 取随机字节 (8.3)' => method_exists(Random\Randomizer::class, 'getBytesFromString'),
'Randomizer 本身 (8.2)' => class_exists(Random\Randomizer::class),
'array_find (8.4)' => function_exists('array_find'),
'array_first (8.5)' => function_exists('array_first'),
];
printf("当前版本: %s\n\n", PHP_VERSION);
foreach ($features as $name => $ok) {
printf("%-36s %s\n", $name, $ok ? '可用' : '不可用');
}在 8.3 上运行它会看到:前三组「可用」,array_find与array_first明确显示「不可用」。这就是把版本事实钉死的办法。
常见坑点
- ❌ 把
array_find、array_any当成 8.3 特性写进代码 ✅ 它们属于8.4
在 8.3 上运行时是Call to undefined function,属于致命错误,不是警告。
- ❌ 依赖
str_contains之类的函数判断环境能力时只看版本号 ✅ 用function_exists/class_exists
扩展可能被禁用,函数存在性比版本号更接近事实。
- ❌ 日志里出现
Deprecated就当噪音忽略 ✅ 在测试环境把E_DEPRECATED打开并当失败处理
这些警告的下一站通常是「在下一个大版本里被直接移除」。
- ❌ 用
set_error_handler把所有级别一把抓成异常,升级到 8.3 后应用直接挂掉 ✅ 只把E_WARNING及以上转成异常,E_DEPRECATED单独记日志
一次性把废弃警告升级成致命错误,等于自己给自己制造停机。
- ❌ 认为「字符串版的
const覆盖成 int 没问题」 ✅ 类型化常量一旦声明,子类的覆盖值必须同类型
否则在类加载阶段就报致命错误,连栈都看不到。
- ❌ 升级 PHP 后不清 opcache ✅ 重启 FPM 或调用
opcache_reset()
旧字节码缓存会导致「有的页面是新行为、有的是旧行为」这种最费时间的现象。
- ❌ 只升级 CLI 的 PHP,忘了 FPM ✅ 用
php_sapi_name()和php_ini_loaded_file()对齐两边
Composer 在 8.3 下装完依赖,FPM 还是 8.1,就会出现「命令行通、网页 500」。
- ❌ 直接在生产上升级并观察 ✅ 先在预发跑完整测试套件与静态扫描
升级前能提前发现的问题,升级后就是事故。
总结
| 变更 | 类型 | 真实版本 | 建议动作 |
|---|
get_class()无参调用 | 废弃 | 8.3 | 全局搜索并补上参数 |
ReflectionProperty::setValue()单参数 | 废弃 | 8.3 | 补上属性对象或改用属性名 |
| 类型化类常量 | 新语法 | 8.3 | 仅在确认无子类覆盖的常量上使用 |
json_validate() | 新函数 | 8.3 | 可替换低频的 decode 校验 |
array_find系列 | 新函数 | 8.4 | 8.3 上不要写 |
clone with、管道操作符 | 新语法 | 8.5 | 更晚的版本才能用 |
random/json类扩展 | 存量 | 8.2 / 8.3 | 先用class_exists探测 |
PHP 8.3 对旧项目最大的影响不是「多了什么」,而是「开始报警」:一批长期存在的写法被标记为废弃,而大多数项目的错误处理器并没有区分警告级别。正确的做法是先对齐版本事实(别把 8.4 的函数当 8.3 用),再用扫描脚本和测试套件在预发环境把告警一次性清干净,最后才动生产环境。