PHP 8.4 属性钩子实战:订单模型重构与 5 个踩坑点

2026-10-22 0 333

先看一段代码,写 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 使用的镜像。

七、迁移节奏:分三步走

不建议一次性把所有模型都用属性钩子重写。实践中有效的顺序大致是这样:

  1. 第一轮:只改那些「纯粹做类型校验」的 setter。也就是 if (!in_array(...)) throw、if ($x < 0) throw、trim() 这类单行逻辑。改完外部调用方几乎不用动,风险最低。
  2. 第二轮:改计算属性。原来写成 getXxx() 但内部什么都不改、只做聚合的方法,转成 get 钩子属性。注意 IDE 和静态分析工具得升级到支持 PHP 8.4 的版本,否则 $order->itemCount 会被标红。
  3. 第三轮:把「外部只读、内部可改」的字段从 private + getter 换成 public private(set)。这一步纯收益,调用方从 $order->getId() 变成 $order->id。

跑完这三轮之后,Order 那个类大概会瘦掉三分之一。再拿 PHPStan 和 Psalm 跑一遍,剩下的告警几乎都在第三方库里,本地代码反而会少一大截——因为原来那些 getXxx(): string 上的返回类型标注没必要再写了。

最后提一句:属性钩子的设计目标不是替代方法,而是把「属性的读取和写入」这一层的逻辑收回来。业务动作该用方法的还是用方法。分不清这条界限的时候,先问自己一句——这段代码是在做数据校验,还是在做业务决策?前者归钩子,后者归方法。

PHP 8.4 属性钩子实战:订单模型重构与 5 个踩坑点
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 php PHP 8.4 属性钩子实战:订单模型重构与 5 个踩坑点 https://www.taomawang.com/server/php/2968.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务