PHP 8.5 把 FETCH_CLASS 的构造参数改成 CUFA 语义后,实体水合会坏在哪
PHP 8.5 里 PDO::FETCH_CLASS 的构造参数传递方式改成和 call_user_func_array()(下称 CUFA)一致。变化本身只有一句话,但对那些用数组第三参数水合实体的代码来说,是一个会直接让请求 500 的回归。
改了什么
旧行为:
- 构造参数数组一律按位置传。数组里带字符串键也没关系,键被忽略,只取值,按顺序塞给构造器。
- 如果构造器参数是引用,PDO 会自动做一次 by-value → by-ref 包装,于是
&$foo这种签名也能跑通。
新行为:
- 和 CUFA 完全一致:整数键 = 位置参数,字符串键 = 命名参数。
- 命名参数在构造器签名里找不到对应形参,直接抛
Error;命名参数只覆盖了一部分、缺必填参数,抛ArgumentCountError。 - 自动的 by-ref 包装没了。构造器签名里有引用参数时,得自己保证传进去的是引用。
最小复现
实体:
final class User
{
public function __construct(
private int $id,
private string $name,
) {}
}
一段在 8.4 上跑得好好的水合代码:
$stmt = $pdo->prepare('SELECT id, name FROM users');
$stmt->execute();
$users = $stmt->fetchAll(PDO::FETCH_CLASS, User::class, [
'user_id' => 0,
'user_name' => '',
]);
8.5 上:
PHP Fatal error: Uncaught Error: Unknown named parameter $user_id
如果字符串键和构造器形参名撞上了一部分,报错形态会变成另一个:
$users = $stmt->fetchAll(PDO::FETCH_CLASS, User::class, ['id' => 0]);
PHP Fatal error: Uncaught ArgumentCountError: Too few arguments to function User::__construct(), 1 passed and exactly 2 expected
(两段报错为运行时的形态示意,消息文本以实际 PHP 版本输出为准。)
为什么这类代码以前一直没炸
因为 FETCH_CLASS 的流程是:先用构造参数把对象造出来,再把结果集的行数据写进属性。构造参数只负责「让对象能构造成功」,真正的字段值是后面那一步覆盖上去的。所以大量代码里第三参数写的是占位值,键名随手起,反正不参与赋值——旧版忽略字符串键,恰好让这种写法一直成立。
新语义下,这些原本「无意义」的键名开始参与参数匹配,问题就暴露了。
定位
先在代码库里搜三个入口:
FETCH_CLASS
fetchAll(PDO::FETCH_CLASS
setFetchMode(PDO::FETCH_CLASS
对每个命中点看第三参数:是变量就顺着往上找赋值,是字面量就看有没有字符串键。构造器那边同时确认形参名和必填项——两边对不上就是嫌疑点。
临时验证可以在调用前打一行:
var_dump($ctorArgs, array_is_list($ctorArgs));
array_is_list() 返回 false 的,就是本次会出问题的那批。
修复
显式转成位置数组。 语义和旧版完全一致,改动最小:
$ctorArgs = array_values([
'user_id' => 0,
'user_name' => '',
]);
$users = $stmt->fetchAll(PDO::FETCH_CLASS, User::class, $ctorArgs);
如果参数本来就是从配置或映射表拼出来的,在拼装处直接 array_values() 更省事,别等到调用点再转。
构造器全部给默认值时,干脆不传第三参数。 很多实体构造器的参数只是为了让属性有初始类型,这种情况第三参数本就不必要:
$users = $stmt->fetchAll(PDO::FETCH_CLASS, User::class);
前提是构造器签名改成 ?int $id = null, string $name = '' 这类可省略形式,行为才和以前等价。
把构造从 SQL 层剥出去,自己水合。 这是最彻底的做法,也顺手解决了引用参数和类型转换的问题:
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$users = array_map(
static fn (array $row): User => new User((int) $row['id'], $row['name']),
$rows,
);
项目里已有映射层(Serializer、自写 hydrator、ORM 的 denormalize)的话,让 PDO 只负责取关联数组,实体构造交给水合器,FETCH_CLASS 的构造参数语义以后怎么改都碰不到你。
引用参数单独处理。 构造器签名里带 &$x 的,新版本不会再自动包装,需要传引用变量:
$ref = null;
$stmt->fetchAll(PDO::FETCH_CLASS, Foo::class, [&$ref]);
这类签名在实体里少见,但 FETCH_CLASS 也常被拿来水合 DTO 和值对象,扫的时候别漏。
setFetchMode() 在 fetch 中途调用
另一个直接抛 Error 的点。以前在循环里改抓取模式不报错,现在不行:
$stmt = $pdo->query('SELECT id, name FROM users');
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if ((int) $row['id'] === 1) {
$stmt->setFetchMode(PDO::FETCH_CLASS, User::class);
}
// ...
}
8.5 上这里抛 Error,抓取过程被中断。修复方式是把设置移到 execute() 之前,或者为不同模式准备两条独立的 statement:
$stmt->setFetchMode(PDO::FETCH_CLASS, User::class);
$stmt->execute();
需要在一批结果里混用两种模式,就拆成两次查询,别在游标打开的语句上改模式。
升级时一并过一遍
error_reporting(E_ALL) 跑全量测试、把 deprecation 日志落盘,这一步照旧。机械替换项:反引号、(integer)/(boolean)/(double) 这类非规范 cast 名、curl_close、imagedestroy、xml_parser_free、Reflection::setAccessible(。__sleep/__wakeup 迁到 __serialize/__unserialize。enum 和布尔值的松散比较现在统一按 (bool)$object,审查 == 的地方。
FETCH_CLASS 水合和 enum 比较是这份清单里最容易静默出错的两项,值得单独排一轮 review。容器侧确认 Dockerfile 和 INI 里不再有 zend_extension=opcache.so 和 disable_classes;Intl 依赖 ICU 57.1+,用 php -i | grep ICU 核对。静态分析工具先升到支持 8.5 的版本,Rector 的 withPhpSets(php85: true) 先跑 --dry-run 看 diff。
其它 8.5 行为变化(非可表示 float 转 int 告警、对非数组做 list()/[] 解构告警、printf 缺精度按 0 处理等)不涉及本次水合问题,按测试反馈处理即可。