PHP 8.4 属性钩子实战:用不可变订单状态模型消灭 getter/setter

2026-08-23 0 971

我最近把项目里那堆 DTO 类全部重写了一遍。原因只有一个:PHP 8.4 的属性钩子加上不对称可见性,让我实在不想再写那种十几行的 getter/setter 了。今天拿一个订单状态模型当例子,说说这两个特性到底能把代码砍掉多少,以及哪些地方容易踩坑。

先定个调子:为什么这俩特性值得学

平时我们写一个订单类,总得有几个字段:ID、创建时间、状态、金额、客户备注。以前我总是这么写:

private string $id;

public function getId(): string
{
    return $this->id;
}

private string $status;

public function getStatus(): string
{
    return $this->status;
}

光这些样板代码就能占掉一大半文件。更烦人的是,很多字段根本不应该在外部被随意赋值,只能通过业务方法去改。以前还得搞一堆 protected setter、模板方法,麻烦得不行。

PHP 8.4 直接改变了游戏规则。属性钩子让你像写计算属性那样玩实际存在的数据字段,而不对称可见性则允许字段公开读、私有写。这两个加一起,订单状态这种场景就舒服太多了。

环境说明

今天的所有代码都基于 PHP 8.4。如果你还没装,赶紧跑一下 php -v 确认版本。属性钩子必须 8.4 以上,别用 8.3 硬跑,不然直接静态编译错误。

先看枚举:订单状态的骨架

状态机最合适用枚举来定义。我的订单有五个状态:创建、已支付、已发货、已完成、已取消。用强枚举能避免传字符串拼错的问题。

<?php

enum OrderStatus: string
{
    case Created = 'created';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Completed = 'completed';
    case Cancelled = 'cancelled';
}

这没什么好说的,PHP 8.1 之后都是标配。

订单类:属性钩子 + 不对称可见性的完全体

接下来是主角。我直接贴完整代码,然后一段一段讲。

<?php

class Order
{
    public private(set) string $id;
    public private(set) DateTimeImmutable $createdAt;
    public private(set) OrderStatus $status;
    public private(set) float $subtotal;
    public private(set) float $tax;

    public string $customerNote {
        set {
            $this->customerNote = trim($value);
        }
    }

    public float $totalAmount {
        get {
            return $this->subtotal + $this->tax;
        }
    }

    public function __construct(float $subtotal, float $tax, string $customerNote = '')
    {
        $this->id = uniqid('ORD-', true);
        $this->createdAt = new DateTimeImmutable();
        $this->status = OrderStatus::Created;
        $this->subtotal = $subtotal;
        $this->tax = $tax;
        $this->customerNote = $customerNote;
    }

    public function markAsPaid(): void
    {
        $this->transition(OrderStatus::Paid, from: [OrderStatus::Created, OrderStatus::Cancelled]);
    }

    public function markAsShipped(): void
    {
        $this->transition(OrderStatus::Shipped, from: OrderStatus::Paid);
    }

    public function markCompleted(): void
    {
        $this->transition(OrderStatus::Completed, from: OrderStatus::Shipped);
    }

    public function cancel(): void
    {
        $this->transition(OrderStatus::Cancelled, from: [OrderStatus::Created, OrderStatus::Paid]);
    }

    private function transition(OrderStatus $target, OrderStatus|array $from): void
    {
        $allowed = is_array($from) ? $from : [$from];

        if (!in_array($this->status, $allowed, true)) {
            throw new LogicException(
                "订单状态不能从 {$this->status->value} 变更为 {$target->value}"
            );
        }

        $this->status = $target;
    }
}

不对称可见性:公开读,私有写

注意这几个属性的声明方式:

public private(set) string $id;
public private(set) DateTimeImmutable $createdAt;
public private(set) OrderStatus $status;

它们的意思是:外部能随便读,但只有类内部能写。这比我以前写的那种 private 加上 public getter 要简洁得多,而且语义更明确——就是告诉读到这儿的人:这些字段的值只能由对象的业务逻辑改变。

如果外部试图直接 $order->status = OrderStatus::Paid,PHP 会在运行时抛出 Error,没法赋值。这能避免大量因为误操作导致状态变乱的问题。

属性钩子:为 set 和 get 加上逻辑

接下来是 $customerNote 属性:

public string $customerNote {
    set {
        $this->customerNote = trim($value);
    }
}

这个属性只声明了一个 set 钩子。意思是:每次给 customerNote 赋值时,都会自动去掉首尾空格。比如用户在订单备注里不小心打了个空格,存进去之前就被清理了。

你可能会疑惑:为什么没有 get 钩子?因为 PHP 8.4 里,如果属性只有 set 钩子,它仍然是一个带存储字段的属性,正常读取返回的就是存储字段本身。所以外部读取 $order->customerNote 的时候,拿到的是经过 trim 之后的值。

在构造函数里我写了:

$this->customerNote = $customerNote;

这会触发 set 钩子,所以即使传进来的备注有空格,也会被清理掉。

我们再看看虚拟属性 totalAmount

public float $totalAmount {
    get {
        return $this->subtotal + $this->tax;
    }
}

这里没有存储字段,只有一个 get 钩子。每次读取这个属性时,动态计算总价。这就是传统意义上的“计算属性”,但它现在可以直接用 $order->totalAmount 访问,不用再写一个 getTotalAmount() 方法。

我还可以加一个“格式化后带货币符号”的属性,甚至跟金额放一起,但那样就偏题了。这个 `totalAmount` 已经足够证明钩子在减少样板代码上的能力。

状态转移方法:还是得用真实方法

有人问:既然有了属性钩子,为什么状态变化不直接靠 set 钩子去校验?我的答案是:状态流转往往不是单靠一个属性赋值就能表达的。

比如 `cancel()` 方法允许从 `Created` 和 `Paid` 两个状态跳转,但 `markAsPaid()` 只允许从 `Created` 跳转。这种复杂规则放在 set 钩子里会让钩子变得巨长,而且要处理错误状态也麻烦。我选择保留传统方法来做状态转移,内部统一用 `transition` 维护一个受控入口。

private function transition(OrderStatus $target, OrderStatus|array $from): void
{
    $allowed = is_array($from) ? $from : [$from];

    if (!in_array($this->status, $allowed, true)) {
        throw new LogicException(
            "订单状态不能从 {$this->status->value} 变更为 {$target->value}"
        );
    }

    $this->status = $target;
}

这里有个很好的点:即使外部没法直接改状态,但类内部的方法可以在校验之后安全地写 $this->status。这正是不对称可见性想要的效果——字段写入完全受控,但读取很自由。

实战体验:这段代码用起来有多爽

看一段使用代码:

<?php

$order = new Order(subtotal: 100.0, tax: 8.0, customerNote: '  注意防撞  ');

echo $order->id;               // ORD-...
echo $order->createdAt->format('Y-m-d H:i:s');
echo $order->status->value;    // created
echo $order->customerNote;     // 注意防撞
echo $order->totalAmount;      // 108

$order->markAsPaid();
echo $order->status->value;    // paid

try {
    $order->status = OrderStatus::Shipped;  // 直接赋值,会报错
} catch (Error $e) {
    echo $e->getMessage();     // Cannot write property
}

$order->markAsShipped();
echo $order->status->value;    // shipped

注意,我故意把 markAsPaid() 写成了允许从 `Cancelled` 状态跳转。实际业务里可能有这样的需求:用户取消后重新支付?也许会有。这只是演示,你完全可以根据自己的业务去调整 `from` 参数。

整个流程里,没有任何一个地方能通过 setter 随意篡改状态。所有状态演变都清晰可见,代码读起来像在写规则,而不是处理一堆私有变量和访客方法。

几个坑,我一开始也绕了很久

坑一:在 set 钩子里别写 $this->xxx = trim($value) 会递归吗?

放心,PHP 8.4 对此做了特殊处理。在钩子内部访问 $this->customerNote 实际上操作的是隐式 backing field,不会再次触发 set 钩子。不过我建议你写代码时心里还是要清楚:set 里写 $this->customerNote = ... 是直接写存储字段,不是调用 setter。

坑二:不对称可见性和 set 钩子同时用,要注意访问控制

我一开始尝试把 $status 声明为 public private(set) OrderStatus $status { set { ... } }。但这样外部无法直接赋值,内部赋值又会触发 set 钩子。当时我只想保护状态字段,加上 set 钩子反而多余,而且容易把逻辑复杂化。所以最后去掉了 status 的 set 钩子,让状态只通过 `transition` 方法调整。

记住:属性钩子和不对称可见性各有各的用,别盲目塞在一起。

坑三:序列化会忽略钩子逻辑

如果你用 `serialize()` 或者 `var_export` 去持久化这个 Order 对象,钩子逻辑不会被序列化。也就是说,反序列化出来的对象可能带着未过滤的 `customerNote`。目前官方推荐用 DTO 模式来绕开这个坑,或者手动在持久化之前做好数据规范化。

更重要的问题:这跟传统 DTO 到底谁香

写惯了 Java 的人可能觉得 getter/setter 更稳定,能加断点调试。但属性钩子同样可以加断点,而且代码量少太多了。我这次重构后,Order 类从原来 150 行缩到了 90 行,并没有降低可读性,反而把状态转移的逻辑集中到了几行方法里。

如果你的项目还在用 PHP 7.4,可能觉得这改变太大。但如果你已经走在 PHP 8.4 的升级路上,这些新特性真的是宝藏。

另外,这种写法对静态分析工具也很友好。PhpStorm 2024.3 以上已经支持属性钩子的语法提示,阅读起来跟普通属性一样,不会出现大片红色波浪线。

总结一下我的重构心得

属性钩子和不对称可见性不是银弹,但它们确实解决了 PHP 面向对象编程里最啰嗦的那个部分:属性声明确实需要更多表达力。现在一个订单状态模型可以同时保证不可变性、可读性和低样板代码。

我建议你拿一个平时自己写的 DTO 试试:把所有的 getter 都改成公开属性,把需要保护的值全用 private(set),然后看看你删了多少行。我当时删完第一反应是:为什么 PHP 现在才来这两样东西。

今天这篇就到这儿。代码全在上面,拿去玩吧,有问题直接在评论区聊。

PHP 8.4 属性钩子实战:用不可变订单状态模型消灭 getter/setter
收藏 (0) 打赏

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

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

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

淘吗网 php PHP 8.4 属性钩子实战:用不可变订单状态模型消灭 getter/setter https://www.taomawang.com/server/php/2591.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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