前言
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
<?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
<?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
<?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
<?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
<?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
// 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
<?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
<?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 版本)是否已经跟上