前言
PHP 8.4(2024 年 11 月发布)带来的语法,和 8.0~8.3 的性质不太一样:8.0 的构造函数属性提升、8.1 的枚举、8.2 的只读类,改的是"写样板代码的方式";而 8.4 的属性钩子(Property Hooks)和非对称可见性(Asymmetric Visibility),改的是数据封装这件事本身的写法。
于是出现了一个很现实的问题:有人把 getter/setter 全换成属性钩子,有人把array_filter全改成array_find,还有人因为用了new Foo()->bar()导致老版本的静态分析工具集体报错。"能用"和"该用"是两件事。
本文按"这项特性解决什么问题 → 正确写法 → 什么情况下不该用"的结构讲清楚 PHP 8.4 的主要变化。代码运行环境都是PHP 8.4+,并会标出哪些其实来自更早的版本——尤其是#[\Override],它属于 PHP 8.3,不是 8.4。
一、属性钩子(Property Hooks)
在 8.4 之前,"读的时候加工、写的时候校验"的属性必须配一对 getter/setter 方法,调用方也得记住方法名。属性钩子让调用方回到"直接用属性"的自然写法,同时保留加工逻辑:
<?php declare(strict_types=1); /** * 属性钩子示例 * 运行环境:PHP 8.4+ */ final class Temperature { /** 私有的存储属性(backing store) */ private float $celsiusValue = 0.0; /** * 对外暴露的虚拟属性(virtual property)。 * 两个钩子都不引用 $this->celsius,所以它不占用存储空间 */ public float $celsius { get => $this->celsiusValue; set (float $value) { if ($value < -273.15) { throw new ValueError('温度不能低于绝对零度'); } $this->celsiusValue = round($value, 2); } } /** 只读的计算属性:没有 set 钩子,外部赋值会抛 Error */ public float $fahrenheit { get => $this->celsiusValue * 9 / 5 + 32; } } $t = new Temperature(); $t->celsius = 25.678; // 走 set 钩子,被 round 到 25.68 echo $t->celsius, PHP_EOL; // 25.68 echo $t->fahrenheit, PHP_EOL; // 78.224 try { $t->celsius = -300; // 走 set 钩子的校验 } catch (ValueError $e) { echo '已拦截: ', $e->getMessage(), PHP_EOL; } try { $t->fahrenheit = 100; // 只读虚拟属性,抛 Error } catch (Error $e) { echo '已拦截: ', $e->getMessage(), PHP_EOL; }几个必须记住的点:
set钩子的参数可以声明类型,声明后 PHP 会做类型校验,这是把类型约束写进钩子的好机会。- 只定义
get不定义set的属性是只读的,外部赋值抛Error(不是静默忽略)。 - 钩子对内外一视同仁。类内部
$this->celsius = 1;同样走set钩子,不会绕过——所以在构造函数里赋值时要留意逻辑是否会被重复触发。
什么情况下不该用:
第一,钩子只能定义在新属性上,它不是"给已有属性挂装饰器"的机制。
第二,不要用它做改名式的封装。如果只是想把$name暴露成别的名字,直接改名就行。钩子的价值在于校验、加工、惰性计算这三类有实际逻辑的场景。
第三,不要和readonly混用。只读语义和"写入时加工"概念上冲突:数据不可变就用readonly(PHP 8.1),需要在写入时加工就用set钩子。
第四,序列化行为会变。虚拟属性不占存储,json_encode($temperature)不会输出celsius这个键——接口字段少了、前端默默拿不到数据,是迁移时最容易踩的坑。需要它出现在 JSON 里就实现JsonSerializable。
二、非对称可见性(Asymmetric Visibility)
"外部可读、不可写"是最常见的封装需求。8.4 之前只能用私有属性加公有 getter 实现,现在一行就够:
<?php declare(strict_types=1); /** 运行环境:PHP 8.4+ */ final class Order { /** 读:public;写:仅限本类内部 */ public private(set) string $status = 'pending'; /** 读:public;写:本类及其子类 */ public protected(set) int $revision = 0; /** 也可以用在构造函数属性提升上 */ public function __construct( public readonly int $id, public private(set) string $channel = 'web', ) {} public function markPaid(): void { $this->status = 'paid'; // 类内部可以写 $this->revision++; } } final class Refund extends Order { public function bump(): void { $this->revision++; // protected(set):子类能写 // $this->status = 'x'; // private(set):只有 Order 自己能写 } } $order = new Order(1001); echo $order->status, PHP_EOL; // 读没问题 try { $order->status = 'cancelled'; // 外部写:抛 Error } catch (Error $e) { echo '已拦截: ', $e->getMessage(), PHP_EOL; } $order->markPaid(); echo $order->status, PHP_EOL; // paid要点:
- 写法是
可见性 (set),括号里是写权限的可见性,比如public private(set)/public protected(set)。 - 读权限必须比写权限更宽松,
private public(set)是语法错误。 - 可以用于构造函数属性提升,这是它最有价值的地方——一行替代"私有属性 + getter"这个样板。
readonly天然就是"外部不可写",所以readonly和(set)不能组合。
该用在哪:领域模型的状态字段——这个约束以前靠约定和代码审查维持,现在可以交给引擎,把团队约定变成引擎强制是它最大的价值。不该用在哪:DTO,public readonly就够了。
一个必须知道的边界:(set)只控制"谁来写",不控制"写成什么"。private(set) string $status依然允许类内部把它设成任意字符串。要约束取值范围,得靠枚举(PHP 8.1 引入):
<?php declare(strict_types=1); // 运行环境:PHP 8.1+(枚举是 8.1 引入的,不是 8.4) enum OrderStatus: string { case Pending = 'pending'; case Paid = 'paid'; } final class SafeOrder { public private(set) OrderStatus $status = OrderStatus::Pending; }private(set)管住"谁能改",枚举管住"能改成什么",两者互补,不是替代关系。
三、array_find家族
PHP 8.4 新增四个查找函数:
| 函数 | 返回 | 找不到时 |
|---|---|---|
array_find(array $array, callable $callback) | 第一个满足条件的元素值 | null |
array_find_key(array $array, callable $callback) | 第一个满足条件的键 | null |
array_any(array $array, callable $callback) | bool,是否有任一元素满足 | false |
array_all(array $array, callable $callback) | bool,是否所有元素满足 | —— |
<?php declare(strict_types=1); /** 运行环境:PHP 8.4+ */ $users = [ ['id' => 1, 'name' => '张三', 'active' => true], ['id' => 2, 'name' => '李四', 'active' => false], ['id' => 3, 'name' => '王五', 'active' => true], ]; $inactive = array_find($users, static fn (array $u): bool => !$u['active']); var_dump($inactive['name'] ?? null); // 李四 $index = array_find_key($users, static fn (array $u): bool => !$u['active']); var_dump($index); // 1 var_dump(array_any($users, static fn (array $u): bool => !$u['active'])); // true var_dump(array_all($users, static fn (array $u): bool => $u['active'])); // false // 空集合的陷阱:array_all 返回 true var_dump(array_all([], static fn (array $u): bool => $u['active'])); // true该不该换的判据,是返回值语义是否刚好对上:
- 老写法
reset(array_filter($arr, $fn))在结果为空时返回false,而array_find()返回null。原代码依赖false判断的话,直接替换会改变行为。 array_find()无法区分"没找到"和"找到的值就是null"。元素可能为null时必须改用array_find_key()配合array_key_exists():
<?php declare(strict_types=1); // 运行环境:PHP 8.4+ $data = ['a' => null, 'b' => 1]; // ❌ 这个 null 是"没找到"还是"找到了一个 null 值"? $v = array_find($data, static fn ($x): bool => $x === null); // ✅ 用 key 版明确区分 $k = array_find_key($data, static fn ($x): bool => $x === null); if ($k !== null && array_key_exists($k, $data)) { echo "找到了键 {$k},它的值是 null\n"; }需要多个结果时也不该换——"找出所有未激活用户再批量处理"就该继续用array_filter,不要因为新函数好看就全量替换。
四、new免括号链式调用与#[\Deprecated]
<?php // PHP 8.4 之前:必须加一层括号,否则解析错误 $request = (new Request())->withMethod('GET'); // PHP 8.4:可以省掉 $request = new Request()->withMethod('GET');这项特性能省一层括号,但链子一长可读性就崩了——new QueryBuilder($pdo)->select('*')->from('users')->where('id', 1)->fetch()这种写法里,new到哪里结束、方法从哪里开始,肉眼很难扫出来,不如拆成两行。
还有一个现实约束:这是解析器层面的新语法,代码若要跑在 PHP 8.3 上,或者项目里的格式化工具、静态分析工具尚未更新,它会成为第一个报错的地方。
<?php declare(strict_types=1); /** #[\Deprecated] 属性,运行环境:PHP 8.4+ */ #[\Deprecated(message: 'use newCalculate() instead', since: '2.1')] function oldCalculate(int $a, int $b): int { return $a + $b; } final class Api { #[\Deprecated(message: '改用 v2 接口', since: '2.1')] public function v1Endpoint(): string { return 'v1'; } } oldCalculate(1, 2); // 触发 E_USER_DEPRECATED- 构造函数签名是
Deprecated::__construct(?string $message = null, ?string $since = null),两个参数都可选,since的内容PHP 不做任何校验,写版本号或日期都行。 - 调用被标记的函数/方法/类常量时抛
E_USER_DEPRECATED,消息里会同时包含since和message。 - 8.4 支持用在函数、方法、类常量上;对 trait 的支持是 PHP 8.5 才加的。
- 反射层面:
ReflectionFunctionAbstract::isDeprecated()会返回true。
规范用法:since一律写版本号,message一律写替代方案。只打标签不给替代说明,调用方看到警告也不知道怎么改。
五、新的 mb_ 函数与 trim 的语义差异
PHP 8.4 补齐了几个多字节字符串函数,签名都是(string $string, ?string $characters = null, ?string $encoding = null):
<?php declare(strict_types=1); /** 运行环境:PHP 8.4+ */ $s = ' 你好世界 '; // 首尾是全角空格 U+3000 var_dump(trim($s) === $s); // true —— trim() 只认 ASCII 空白,完全没起作用 var_dump(mb_trim($s)); // "你好世界" var_dump(mb_ucfirst('éclair')); // "Éclair" var_dump(mb_lcfirst('Éclair')); // "éclair"新增的是mb_trim()/mb_ltrim()/mb_rtrim()/mb_ucfirst()/mb_lcfirst()。两个必须注意的语义变化:
(1)mb_trim()的默认字符表比trim()宽得多。trim()的默认字符表是" \t\n\r\0\x0B",只覆盖 ASCII 空白;mb_trim()把第二个参数改成可空的?string $characters = null,为null时去掉的是整个 Unicode 分隔符(Separator,Z 类别):不间断空格 U+00A0、全角空格 U+3000、各类宽度空格(U+2000~U+200A)、行分隔符 U+2028、段落分隔符 U+2029 等。这就是上面全角空格能被去掉的原因。
(2)mb_trim()不支持范围简写语法。trim('testABC', 'A...E')里的A...E会被展开成ABCDE,但mb_trim()系列不认这种写法,A...E会被当成字面的五个字符。老代码里用到范围简写的,迁移后会静默改变语义——不报错,但结果不对。
常见坑点
1. 把#[\Override]当成 PHP 8.4 的特性
❌ 写成"#[\Override]是 8.4 新增的"。 ✅#[\Override]是 8.3 引入的,#[\Deprecated]才是 8.4;同理mb_str_pad()是 8.3,mb_trim()系列才是 8.4。
2. 全量替换 getter/setter,导致 JSON 字段消失
❌ 把getCelsius()换成public float $celsius { get => ...; }之后,接口返回的 JSON 里celsius不见了——因为虚拟属性不占存储,json_encode()不会序列化它。 ✅ 需要出现在 JSON 里就实现JsonSerializable::jsonSerialize(),或者改成"有存储的属性 +get钩子"。
3. 在set钩子里写同名属性,语义含糊
❌public string $name { set (string $v) { $this->name = strtolower($v); } }—— 这个"同名属性"究竟是写存储还是递归调用钩子? ✅ 用名字不同的私有存储属性:private string $nameValue;+set (string $v) { $this->nameValue = strtolower($v); },显式无歧义。
4. 用array_find()的结果做=== false判断
❌if (array_find($arr, $fn) === false)—— 找不到时返回的是null不是false,这个条件永远不成立。 ✅ 用=== null。批量迁移时把每个函数的"空值语义"逐个核对,是唯一可靠的办法。
5. 把array_all()用在空集合上
❌if (array_all($orders, fn($o) => $o->shipped)) { echo '全部已发货'; }—— 一个订单都没有时返回true,界面提示明显错误。 ✅ 显式判空:if ($orders !== [] && array_all($orders, ...))。
6.public private(set)写成private public(set)
❌private public(set) string $status;—— 语法错误,写权限不能比读权限更宽松。 ✅ 记法:前面的是"读"的可见性,括号里的是"写"的可见性,读一定比写更宽松。
7. 以为private(set)能约束取值范围;直接替换传了自定义字符表的trim()
❌public private(set) string $status;—— 类内部任何位置赋任意字符串都能通过。它只管"谁写",不管"写成什么",取值范围要靠枚举(8.1)或set钩子校验。 ❌ 把trim($s, 'A...E')改成mb_trim($s, 'A...E')就上线 ——mb_trim()不认范围简写,会把它当字面五字符。改动不报错,结果却变了。迁移前要把自定义字符表展开成显式列表再逐个确认。
总结
| 特性 | 引入版本 | 该用 | 不该用 |
|---|---|---|---|
| 属性钩子 Property Hooks | PHP 8.4 | 需要校验/加工/惰性计算的属性 | 简单直通属性;与readonly混用 |
非对称可见性private(set) | PHP 8.4 | 领域模型的受控状态字段 | DTO(用readonly就够) |
array_find/array_find_key/array_any/array_all | PHP 8.4 | 语义正好对上"找第一个/是否存在/是否全部" | 需要全部结果(用array_filter);元素可能为null |
new Foo()->bar()免括号 | PHP 8.4 | 短链式调用 | 长链式调用;工具链未升级时 |
#[\Deprecated] | PHP 8.4 | 标记自有 API 的废弃 | 只写标签不给替代方案 |
#[\Override] | PHP 8.3(不是 8.4) | 标记覆写父类方法 | —— |
mb_trim/mb_ltrim/mb_rtrim/mb_ucfirst/mb_lcfirst | PHP 8.4 | 处理含全角空白、带重音符的字符串 | 直接替换传了自定义字符表的trim() |
mb_str_pad | PHP 8.3(不是 8.4) | 多字节填充 | —— |
PHP 8.4 这批语法里最值得用起来的是属性钩子和非对称可见性:前者把散落各处的校验逻辑收拢到属性定义上,后者把"只读对外"这个团队约定变成了引擎强制。它们不是"更酷的写法",而是让一类 bug 从"靠人记住"变成"编译器不放过"。
至于array_find家族、免括号new、新的mb_*函数,都属于局部改善可读性的小工具,用不用都不影响架构质量。但用之前一定要核对:返回值语义是否真的对得上,工具链(格式化、静态分析、部署环境的 PHP 版本)是否已经跟上。