PHP 版本迁移指南(baserCMS) 是做什么的?
PHP 版本升级中遇到的弃用和破坏性变更及修复配方集。按版本整理并持续更新。CakePHP 框架本身引起的问题请参考另一技能 cakephp-migration。
收录:PHP 8.2 / 8.4 / 8.5。新版本支持时添加新的 "## PHP X.Y" 小节。 注意:PHP 8.5 是 9.0 的前一个版本。9.0 中将移除所有弃用功能,因此 8.5 中出现的弃用警告应尽早修复。
大原则
- 区分“致命错误”和“弃用警告”。调试模式下弃用警告会显示在屏幕底部,看起来像“错误”,但
logs/debug.log中以debug:输出的只是警告(程序继续运行)。Fatal/Exception 才是真正的错误。 - 区分回归与已有失败。使用
git diff HEAD -- <file>检查文件是否属于你的修改范围。 - 修改父类签名后,子类和接口也必须相应调整。
- 使用 Rector 等静态分析工具进行全面的弃用检测。
单元测试执行方法、语法检查、失败分析请参考
basercms-unittest技能。php -l(语法检查)也会输出隐式 nullable 等弃用警告。
PHP 8.2
动态属性的弃用
对未声明属性的赋值已被弃用。出现 Creation of dynamic property X::$prop is deprecated。
- 方案A(推荐):在类中声明属性。
※ 典型情况:构造函数中类似/** @var ThemeFoldersServiceInterface */ protected $ThemeFoldersService; protected $_tempalteTypes = [];$this->Xxx = $this->getService(...)的赋值。 - 方案B:对于影响众多子类的基类(例如测试基类
BcTestCase有超过400个子类),使用#[ AllowDynamicProperties]。属性会被子类继承,一处修改即可。#[
AllowDynamicProperties] class BcTestCase extends TestCase { ... }
---
## PHP 8.4
### 1. 隐式 nullable 参数的弃用
`类型 $x = null` 是隐式 nullable,已被弃用。应显式写为 `?类型`。
```php
// Before
function foo(int $contentId = null, BcAbstractDetector $agent = null)
// After
function foo(?int $contentId = null, ?BcAbstractDetector $agent = null)
- 检测(正则表达式):匹配开头没有
?且形式为类型 $name = null的参数。 例如:([A-Za-z_\\][A-Za-z0-9_\\]*)\s+(\$[A-Za-z0-9_]+)\s*=\s*null(前面没有?)。 - 修改父类签名时,子类/接口也要同步修改。
- 警告信息:
Implicitly marking parameter $x as nullable is deprecated, the explicit nullable type must be used instead。
2. fgetcsv() 的 $escape 参数弃用
省略 $escape 参数已被弃用,需显式指定。
// Before
$head = fgetcsv($fp, 10240);
// After
$head = fgetcsv($fp, 10240, ',', '"', '\\');
- 自定义实现(如使用
fgets+正则表达式解析 CSV 的函数)未使用fgetcsv,无需修改。仅需修改实际调用fgetcsv()的代码。
3. ReflectionProperty::setValue() 的单参数调用弃用
对于非静态属性,需要传入目标对象。
// Before
$property->setValue($value);
// After
$property->setValue($object, $value);
- 警告信息:
Calling ReflectionProperty::setValue() with a single argument is deprecated。 - 常见于测试工具(请求生成或私有属性操作的辅助函数)中。
- 对于静态属性,第一个参数传入
null:setValue(null, $value)。这在 PHP 8.1 至 8.5 均有效(不会破坏 8.1 兼容性)。
4. 向内部函数的 string 参数传递 null 已被弃用(实际始于 8.1)
parse_url(null) / version_compare($a, null) / str_replace(..., null) 等,向非 ?string 参数传递 null 会触发 Passing null to parameter #N ($x) of type string is deprecated。使用 (string) 强制转换显式转换。
// Before → After
parse_url($url); → parse_url((string)$url);
version_compare($current, $required); → version_compare((string)$current, (string)$required);
- 该弃用在 8.1 引入,但在迁移工作(尤其是升级到 8.4/8.5)中大量出现,因此也收录于此。
- 仅在运行时值为 null 时才会触发,无法通过静态 grep 完全捕获。应汇总所有测试日志中的
Passing null to parameter来定位来源。
PHP 8.5(2025-11-20 发布)
9.0 前的最后一个次要版本。以下大部分是警告,程序可以继续运行,但将在 9.0 中移除,因此需要处理。按对应用/框架影响大小排序。※ 只有“0.”是致命错误(破坏性变更),需要最优先处理。
⚠️ 在保持 PHP 8.1 兼容性期间的注意事项(
composer.json中的"php": ">=8.1")。 如果按照官方推荐的替换方案修复 8.5 弃用,该替换方案可能在 8.1 中不存在,导致 8.1 出现致命错误而无法运行。以下 ❌ 标记的项目应推迟处理,直到放弃 8.1 支持(8.5 下会发出警告,但可以接受)。在 8.5 下运行本身不会破坏 8.1 兼容性(仅警告)。
- ❌
str_increment()(8.3+)→ 8.1 下保持$s++- ❌
http_get_last_response_headers()(8.4+)→ 8.1 下保持$http_response_header- ❌ 命名空间 PDO(如
Pdo\Mysql::ATTR_*,8.4+)→ 8.1 下保持PDO::MYSQL_ATTR_*其余项目(非标准类型转换、
null数组偏移→''、自动释放函数移除、反引号→shell_exec、case ;、__serialize)可以安全地在 8.1 下应用。
0. 【破坏性变更·致命错误】使用 use 引用保留类名(如 Array)会导致致命错误
8.5 中 Array 等被保留为特殊类名,use ...\Array; 或 class Array 会引发致命错误(非警告)。如果在测试收集(自动加载)阶段崩溃,会导致整个测试套件无法启动,需要最优先处理。
Fatal error: Cannot use BcMail\Test\TestCase\Model\Array as Array because 'Array' is a special class name
- baserCMS 实例:
MailMessagesTableTest.php开头遗留了 CakePHP2 时代的use ...\Array;/use ...\ClassRegistry;(都是不存在的类的死亡 import),在 8.4 之前无害,但在 8.5 中变成致命错误。删除对应的use行即可解决(引用处是markTestIncomplete的未执行代码)。 - 检测:
grep -rnE "use [A-Za-z0-9_\\\\]+\\\\(Array|Object|Resource|Enum|Mixed|Never|Void|Null|False|True|Iterable|Numeric)\s*;" --include="*.php"。 - 注意:由于是致命错误,单个文件可以通过
php -l检测。但在运行所有测试前用 grep 清理一遍,可以防止套件在收集阶段崩溃。
1. 非标准类型转换名称的弃用(影响大)
(boolean) (integer) (double) (binary) 已被弃用。替换为标准名称。
// Before → After
(boolean) → (bool)
(integer) → (int)
(double) → (float)
(binary) → (string)
- 检测:
grep -rnE "\\((boolean|integer|double|binary)\\)" --include="*.php"。 - 常见于旧代码中。可以机械地替换。
2. 使用 null 作为数组偏移已被弃用
$arr[null] / array_key_exists(null, $arr) 等使用 null 作为键已被弃用(之前会隐式转换为 "")。应显式使用空字符串 ""。
// Before
$arr[null] = $v; array_key_exists(null, $arr);
// After
$arr[''] = $v; array_key_exists('', $arr);
- 注意变量作为键的情况(
$arr[$key]中$key可能为 null)。 - 静态 grep 难以检测(仅在运行时值为 null 时触发)。在 8.5 下运行所有测试,然后使用
grep "null as an array offset" phpunit.log汇总来源(on line N of <file>)最为可靠。 - 修改策略:① 将赋值/引用的键改为
$key ?? ''(与之前的隐式转换等效。确保 set/get 使用相同的键)、② 在isset()/引用前添加$key !== null &&守卫。对getData('batch')等可能返回 null 的情况使用?? ''接收也很有效。 - baserCMS 实例(共 308 处):
BcFileUploader(uploadingFiles[$bcUploadId]的 set/get 使用?? ''保持一致)、BcUploadBehavior(oldEntity[$alias][$entity->_bc_upload_id]使用?? '')、MailMessagesTable($dists[$mailField->group_valid])、SearchIndexes/Contents/PermissionsController(getData('batch') ?? '')、BcTextHelper::arrayValue/BcUtil::decodeContent($key !== null && isset(...))。
3. 自动释放对象的资源释放函数弃用
这些函数处理的对象已被 GC 自动释放,因此显式调用 close/free 已被弃用。直接移除调用即可。
curl_close()/curl_share_close()(CurlHandle / CurlShareHandle)finfo_close()(finfo)imagedestroy()(GdImage)← 常见于图像处理工具中xml_parser_free()(XMLParser)- 检测:
grep -rnE "\(curl_close|curl_share_close|finfo_close|imagedestroy|xml_parser_free)\s*\\(" --include="*.php"。 - baserCMS 实例:
Imageresizer.php和BcFileUploader.php中的 4 处imagedestroy()。可以直接删除,但为了保持循环内立即释放内存的意图,建议替换为unset($img)(GdImage 是对象,解除引用后 GC 会释放。>=8.1 安全)。
3-2. ReflectionProperty/Method::setAccessible() 的弃用(发生数量最多)
setAccessible() 在 PHP 8.1 之后是空操作(可以直接通过 getValue/setValue/invoke 访问 private/protected 成员)。8.5 中正式弃用。直接删除该调用行即可(>=8.1 安全)。
// Before
$property = new ReflectionProperty($obj, 'foo');
$property->setAccessible(true); // ← 删除此行
$property->setValue($obj, $v);
- baserCMS 实例:src/测试中共 33 处(
BcUtil/BcTestCase/BaserCorePlugin/PreviewController/BcDatabaseService等)。由于位于热点路径(如测试基类),运行时发生次数异常多(所有测试中约 6300 次 ≈ 8.5 弃用的大部分)。首先修复此项可以大幅减少日志。 - 批量删除(仅限独立语句):
sed -i -E "/^[[:space:]]*\$[A-Za-z0-9_]+->setAccessible\(true\);[[:space:]]*$/d" <file>。 - 检测注意事项:如果 grep 模式以
-开头,如->setAccessible,grep 会误认为选项而返回 0 结果。应避免以-开头(如grep -rn "setAccessible"),或使用grep -e。
4. 非数值字符串的递增弃用
$str++(非数值字符串)已被弃用。请使用 str_increment()。※ str_increment() 需要 8.3+。保持 8.1 兼容性期间,请保留 $str++。
// Before
$code++; // $code 为 'AZ' 等
// After
$code = str_increment($code);
5. __sleep() / __wakeup() 的软弃用
请使用 __serialize() / __unserialize()(如果不需要 PHP7 兼容性)。
6. $http_response_header 的弃用
超级全局变量 $http_response_header 已被弃用。请使用 http_get_last_response_headers()。※ http_get_last_response_headers() 需要 8.4+。保持 8.1 兼容性期间,请保留 $http_response_header。
7. 反引号运算符的弃用
`command`(shell_exec() 的别名)已被弃用。请直接使用 shell_exec()。
8. 使用 ; 结束 case 语句被弃用
case 1; 已被弃用。请改为 case 1:。
- 检测:
grep -rnE "case\\s+.+;\\s*$" --include="*.php"(误报较多,需目视确认)。
8-2. array_key_exists(null, ...) 的弃用(由序列化的错误形式触发)
array_key_exists() 的第一个参数为 null 已被弃用(应使用空字符串)。如果问题出现在框架内部(如 CakePHP JsonView::_dataToSerialize),原因通常是自定义代码中 serialize 选项的形式错误。如果在 setOption('serialize', ['foo', 'message' => $message]) 中将 'key' => 值 混入变量名列表,当 $message 为 null 时,序列化数组会包含 'message' => null,从而触发 array_key_exists(null, ...)。
- 修复:serialize 应为视图变量名的列表。将
['foo', 'message' => $message]改为['foo', 'message'](值已通过$this->set()设置)。baserCMS 实例:MailMessagesController的 index/view 操作。 - 当问题来源位于框架内部时的定位方法:临时修改 vendor 代码(
if ($key === null) file_put_contents(..., var_export($serialize,true).getTraceAsString()))→ 运行对应插件的测试 → 根据堆栈跟踪定位顶部的自定义控制器 → 恢复 vendor。
8-3. 向 ArrayObject 传递对象作为 backing array 被弃用
向 new ArrayObject($obj) 传递对象(如 json_decode() 返回的 stdClass)已被弃用。请强制转换为数组。
- 修复:
new ArrayObject(json_decode($body)->devices)→new ArrayObject((array)json_decode($body)->devices)。baserCMS 实例:SitesControllerTest。
9. 其他(按需处理)
- PDO 驱动特定的常量和方法迁移到命名空间形式(
PDO::MYSQL_ATTR_*→Pdo\Mysql::ATTR_*,PDO::sqliteCreateFunction()→Pdo\Sqlite::createFunction()等)。大多数在框架内部,但如果自定义代码中使用了 DB 驱动常量,则需要处理。PDO 的"uri:"DSN 方案也被弃用。※ 命名空间 PDO 类需要 8.4+。保持 8.1 兼容性期间,请保留PDO::MYSQL_ATTR_*。 openssl_pkey_derive()的$key_length参数和finfo_buffer()的$context参数被弃用(会被忽略)。- 非 CLI SAPI 中的
register_argc_argvINI 设置被弃用(请使用$_GET和$_SERVER['QUERY_STRING'])。 - 对静态闭包进行 bind 等部分闭包绑定操作被弃用。
clone null的错误消息变更(影响测试):8.4 之前为__clone method called on non-object,8.5 为clone(): Argument #1 ($object) must be of type object, null given。如果测试通过字符串匹配验证clonenull 的异常,则需要更新预期值(baserCMS 实例:UploaderCategoriesControllerTest的 Admin/Api 各test_copy)。
⚠️ 全测试运行时出现连锁错误应先重试以确认是否为偶发:本代码库中,全量运行时偶尔会出现“某个先运行的测试污染了全局状态(如插件配置 Configure)→ 后续几乎所有测试在 Application bootstrap 阶段因
PluginCollection::create(): $config ... null given等而连锁错误”的一过性污染。如果单独运行插件的测试套件(如plugins/baser-core/tests/TestCase)正常,且重试后变干净,则问题不是代码引起的,而是脆弱的测试(flaky test)。务必通过隔离和重试来确认。
调查步骤(检查清单)
- 使用
php -l(语法检查)或检查logs/debug.log,确定弃用警告发生的文件和行号。 - 对照对应版本的章节,分类属于哪种模式并修复。可以机械替换的(如非标准类型转换)使用 grep 批量检测。
- 使用
php -l再次确认(警告是否消失)。 - 使用
--filter单独运行相关测试 → 最后重新运行所有测试。
框架相关的问题(事件返回值、Association 重复、ResultSet、FormProtection 等)请参考 cakephp-migration 技能。