先看一段代码,写 PHP 的都写过:
final class Order
{
private string $status = 'new';
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): void
{
if (!in_array($status, ['new', 'paid', 'shipped', 'cancelled'], true)) {
throw new InvalidArgumentException("未知状态: {$status}");
}
$this->status = $status;
}
}
这段代码本身没什么毛病,问题是它成倍出现的时候。Order 有 8 个字段,就要写 16 个方法;字段增加到 12 个,就有 24 个方法。构造函数、反序列化、DTO、表单绑定、Symfony 的 normalizer 全都要适配这堆方法名。更烦的是每次新增一层校验就得再开一个 setter,时间长了,setStatus 里的业务逻辑比模型本身还长。
PHP 8.4 的属性钩子(Property Hooks)就是冲着这个问题来的。本文不铺开讲 RFC,直接拿一个真实订单模型走一遍重构,顺手把几个写下去才会踩到的点提前点出来。
一、基本语法:钩子长什么样
一个带钩子的属性由三部分组成:类型声明、属性名、以及一对花括号里的 get 或 set 块。没钩子的一端和不写就一样——也就是说,只写 get 的属性对外是只读的,只写 set 的属性对外不可读(读它会报错,除非你还声明了别的方式)。
final class Demo
{
public string $name = '' {
get => strtoupper($this->name);
set => trim($value);
}
}
先注意两个要点。
第一,set => trim($value) 这种简写形式里,$value 是赋值时传进来的值,写法上不需要在括号里再声明一次参数。简写形式的语义是「表达式的计算结果就是最终存进属性的值」,所以 trim($value) 的结果会被存下来。这里不需要写 $this->name = ...,返回值本身就会落到存储里。
第二,get => strtoupper($this->name) 里,$this->name 不会再次触发 get 钩子,它的意思是「读取这个属性自身的底层存储」。如果不这样绕过,第一行就无限递归了。这个约定同样适用于 set 块内部——写 $this->name = $x 会直接写存储,不会再次触发 set,所以在块语法里做校验时可以放心地写赋值语句。
记住这两条,下面所有代码都能读通了。
二、重构前:订单模型的完整样子
为了说明迁移前后的差异,这里先给出原始写法,字段不多,但覆盖了典型场景:
final class Order
{
public const STATUSES = ['new', 'paid', 'shipped', 'cancelled'];
private string $status = 'new';
private int $quantity = 1;
private string $note = '';
private array $items = [];
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): void
{
if (!in_array($status, self::STATUSES, true)) {
throw new InvalidArgumentException("未知状态: {$status}");
}
$this->status = $status;
}
public function getQuantity(): int
{
return $this->quantity;
}
public function setQuantity(int $quantity): void
{
if ($quantity quantity = $quantity;
}
public function getNote(): string
{
return $this->note;
}
public function setNote(string $note): void
{
$this->note = trim($note);
}
public function getItemCount(): int
{
return count($this->items);
}
}
一共 5 个私有字段、4 个 getter、3 个 setter,外加一个算总数的计算属性方法。数量不多,但模式很死。
三、重构后:把校验搬进属性
final class Order
{
public const STATUSES = ['new', 'paid', 'shipped', 'cancelled'];
public string $status = 'new' {
set (string $value) {
if (!in_array($value, self::STATUSES, true)) {
throw new InvalidArgumentException("未知状态: {$value}");
}
$this->status = $value;
}
}
public int $quantity = 1 {
set (int $value) {
if ($value quantity = $value;
}
}
public string $note = '' {
set => trim($value);
}
public array $items = [];
public int $itemCount {
get => count($this->items);
}
}
对比一下:
- 4 个私有字段全部变成了带可见性的公有属性。
private加 getter/setter 的封装在属性钩子面前没什么必要了——校验逻辑本身就在set块里,外部照样改不动。 - 3 个 setter 变成了 3 个
set块。写起来几乎不变,但调用方从$order->setStatus('paid')变成了$order->status = 'paid'。 getItemCount()变成了$itemCount属性,而且是没有set的虚属性——对它的任何赋值都会抛出Error,不需要额外写一个空方法去拦截。
调用方代码从这样:
$order->setStatus('paid');
$order->setQuantity(3);
$order->setNote(' 加急 ');
echo $order->getItemCount();
变成这样:
$order->status = 'paid';
$order->quantity = 3;
$order->note = ' 加急 ';
echo $order->itemCount;
note 的 set 钩子依然生效,var_dump($order->note) 出来是去掉空格的 加急。
更关键的一点是,这之后所有的序列化、反序列化、Symfony 的 ObjectNormalizer、Laravel 的 fill()、反射构造型 ORM 都能直接对属性赋值,不需要再为每个字段单独适配 setter 的名字。这一点在表单绑定和 JSON 映射场景下尤其省事。
四、虚属性 + 不对称可见性:把边界收紧
有时不想让属性对外写,但也不打算用 readonly——因为在对象生命周期里这个值可能会变几次。PHP 8.4 引入了不对称可见性,可以写成:
final class Order
{
public private(set) string $trackingNo = '';
public function ship(string $trackingNo): void
{
$this->trackingNo = $trackingNo;
}
}
public private(set) 的含义是:外部可以读,写操作只允许在 Order 类内部进行。对 $order->trackingNo = 'XXX' 这样的外部赋值会直接抛 Error。
注意这跟 readonly 的区别:readonly 一个属性一辈子只能被赋值一次,而且必须在声明作用域里赋;public private(set) 没有次数限制,只是把写权限收进了类内部。很多原先为了「不允许外部改」而写 private 字段 + getter 的场景,其实用 public private(set) 更准确——它有默认值、可以在构造之后继续通过内部方法修改,语义也更贴近真实业务。
顺带说一句,public private(set) 和属性钩子不能随意混用。如果你给一个属性写了一个自定义的 set 钩子,同时又加 private(set),那么钩子内部对 $this->prop = ... 的赋值依然能写底层存储——但这份权限只在类内部生效;外部赋值会在调钩子之前就被可见性挡掉。这个组合我没在实际项目里找到必须用的场景,但跑得通。
五、5 个容易踩到的坑
坑 1:属性必须声明类型
无类型属性不能挂钩子。写 public $name { get => ...; } 会直接语法报错。同理,联合类型是可以的(string|int),但 mixed 虽然合法,$value 可做的处理就受限了。实际重构时,给每个字段补类型这一步是躲不掉的,硬着头皮做完,顺便能扫出一票历史遗留问题。
坑 2:和 readonly 互斥
一个属性不能同时声明 readonly 和 get/set 钩子。readonly 本身的语义就是「初始化后不可再改」,和自定义 set 逻辑在设计上冲突,引擎直接拒绝组合。如果原先代码里用 readonly 是为了做校验,比如在构造函数里 if 一下再赋值,那最好的替代是「private(set) + 构造函数里赋值」,或者干脆用 set 钩子来做真正的校验。
坑 3:魔术方法不会被触发
给属性加了钩子之后,__get、__set、__isset 这些魔术方法不会因为访问该属性而被调用。这不是 bug,而是设计如此——钩子的优先级高于魔术方法。如果你的框架依赖 __get 做懒加载(比如 Doctrine 的代理对象),把原属性改成钩子属性后,代理逻辑可能失效。迁移之前,先把涉及懒加载的模型标出来。
坑 4:ReflectionProperty 会绕过钩子
反射直接读写底层存储的方式不受钩子影响。$ref->setValue($obj, 'xxx') 会跳过 set 钩子里的所有校验,直接写进去。这在测试里挺好用(绕开校验塞脏数据),但如果你有一层「用反射统一写属性」的通用序列化器,要注意它对带钩子的属性不会做任何校验。需要的话就得改用 $obj->prop = ... 的正规写法。
坑 5:set 块里必须显式写赋值
简写形式 set => trim($value) 会自动存储返回值,但块语法不是。下面这种写法是错的:
public int $quantity = 1 {
set (int $value) {
if ($value < 1) {
throw new InvalidArgumentException('数量至少为 1');
}
// 忘了写 $this->quantity = $value;
}
}
不写赋值的话,属性会保持原值。代码不会报错,行为却悄悄变了——这是最容易在代码评审时混过去的一个坑。要么老老实实写 $this->quantity = $value;,要么干脆写成简写形式 set => $value < 1 ? throw new InvalidArgumentException(...) : $value,让引擎自己存值。
六、什么时候还是别用属性钩子
属性钩子确实好用,但有几类场景用上之后反而更难受:
- 业务逻辑很重的 setter。比如「设置状态时通知订单服务、发消息、写审计日志」这种操作,本质上是领域事件,不该藏在属性的赋值动作里。这种时候保留
ship()、markAsPaid()这样的显式方法更清晰。 - 需要返回引用。
&$obj->items这种写法不能用在带钩子的属性上。要原地修改数组元素,得把items拿出来改完再写回去,或者改成通过方法修改。 - 大量动态属性。如果你的模型字段是运行时根据元数据拼出来的,那还是
__get/__set更合适。属性钩子是为静态类型声明的属性服务的。 - 需要严格区分 null 和未定义。带钩子的属性必须有默认值或允许 null 类型,跟
isset()的语义配合要仔细测,别指望它完全替代__isset。
另外还有一个现实约束:项目还跑在 PHP 8.3 或者更低版本上时,这段代码连解析都会失败,不会退化、不会警告,直接语法错误。所以在引入之前先确认 composer.json 里的 php 约束,以及 CI 使用的镜像。
七、迁移节奏:分三步走
不建议一次性把所有模型都用属性钩子重写。实践中有效的顺序大致是这样:
- 第一轮:只改那些「纯粹做类型校验」的 setter。也就是
if (!in_array(...)) throw、if ($x < 0) throw、trim()这类单行逻辑。改完外部调用方几乎不用动,风险最低。 - 第二轮:改计算属性。原来写成
getXxx()但内部什么都不改、只做聚合的方法,转成get钩子属性。注意 IDE 和静态分析工具得升级到支持 PHP 8.4 的版本,否则$order->itemCount会被标红。 - 第三轮:把「外部只读、内部可改」的字段从
private + getter换成public private(set)。这一步纯收益,调用方从$order->getId()变成$order->id。
跑完这三轮之后,Order 那个类大概会瘦掉三分之一。再拿 PHPStan 和 Psalm 跑一遍,剩下的告警几乎都在第三方库里,本地代码反而会少一大截——因为原来那些 getXxx(): string 上的返回类型标注没必要再写了。
最后提一句:属性钩子的设计目标不是替代方法,而是把「属性的读取和写入」这一层的逻辑收回来。业务动作该用方法的还是用方法。分不清这条界限的时候,先问自己一句——这段代码是在做数据校验,还是在做业务决策?前者归钩子,后者归方法。

