
前言PHP 8.42024 年 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_types1); /** * 属性钩子示例 * 运行环境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混用。只读语义和写入时加工概念上冲突数据不可变就用readonlyPHP 8.1需要在写入时加工就用set钩子。第四序列化行为会变。虚拟属性不占存储json_encode($temperature)不会输出celsius这个键——接口字段少了、前端默默拿不到数据是迁移时最容易踩的坑。需要它出现在 JSON 里就实现JsonSerializable。二、非对称可见性Asymmetric Visibility外部可读、不可写是最常见的封装需求。8.4 之前只能用私有属性加公有 getter 实现现在一行就够?php declare(strict_types1); /** 运行环境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)不能组合。该用在哪领域模型的状态字段——这个约束以前靠约定和代码审查维持现在可以交给引擎把团队约定变成引擎强制是它最大的价值。不该用在哪DTOpublic readonly就够了。一个必须知道的边界(set)只控制谁来写不控制写成什么。private(set) string $status依然允许类内部把它设成任意字符串。要约束取值范围得靠枚举PHP 8.1 引入?php declare(strict_types1); // 运行环境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)第一个满足条件的元素值nullarray_find_key(array $array, callable $callback)第一个满足条件的键nullarray_any(array $array, callable $callback)bool是否有任一元素满足falsearray_all(array $array, callable $callback)bool是否所有元素满足——?php declare(strict_types1); /** 运行环境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_types1); // 运行环境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_types1); /** #[\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_types1); /** 运行环境PHP 8.4 */ $s 你好世界 ; // 首尾是全角空格 U3000 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()。两个必须注意的语义变化1mb_trim()的默认字符表比trim()宽得多。trim()的默认字符表是 \t\n\r\0\x0B只覆盖 ASCII 空白mb_trim()把第二个参数改成可空的?string $characters null为null时去掉的是整个 Unicode 分隔符SeparatorZ 类别不间断空格 U00A0、全角空格 U3000、各类宽度空格U2000~U200A、行分隔符 U2028、段落分隔符 U2029 等。这就是上面全角空格能被去掉的原因。2mb_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.3mb_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 HooksPHP 8.4需要校验/加工/惰性计算的属性简单直通属性与readonly混用非对称可见性private(set)PHP 8.4领域模型的受控状态字段DTO用readonly就够array_find/array_find_key/array_any/array_allPHP 8.4语义正好对上找第一个/是否存在/是否全部需要全部结果用array_filter元素可能为nullnew 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_lcfirstPHP 8.4处理含全角空白、带重音符的字符串直接替换传了自定义字符表的trim()mb_str_padPHP 8.3不是 8.4多字节填充——PHP 8.4 这批语法里最值得用起来的是属性钩子和非对称可见性前者把散落各处的校验逻辑收拢到属性定义上后者把只读对外这个团队约定变成了引擎强制。它们不是更酷的写法而是让一类 bug 从靠人记住变成编译器不放过。至于array_find家族、免括号new、新的mb_*函数都属于局部改善可读性的小工具用不用都不影响架构质量。但用之前一定要核对返回值语义是否真的对得上工具链格式化、静态分析、部署环境的 PHP 版本是否已经跟上。