我最近把项目里那堆 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 现在才来这两样东西。
今天这篇就到这儿。代码全在上面,拿去玩吧,有问题直接在评论区聊。

