升级到 PHP 8.4 之后,除了属性钩子,我还被不对称可见性吸引了。这俩一结合,我立马想到了之前写的一个 Money 类。那会儿为了确保金额不能是负数,又不能让外部直接修改金额,我写了一堆 getter 和 setter,看着就烦。现在好了,用属性钩子重新写一遍,整个类从 80 行缩到 40 行。
这篇就拿 Money 值对象作为案例,一步步给你看我怎么用上 PHP 8.4 的。
一、属性钩子到底改了啥
在 PHP 8.4 之前,你没办法让一个公共属性具备代码逻辑。想校验赋值,就只能写 setter。但属性钩子让公共属性自带 get 和 set 行为,从语法层面消灭了那些冗长的访问器。比如这样:
class Point {
public int $x {
get => $this->_x;
set (int $value) {
if ($value _x = $value;
}
}
private int $_x = 0;
}
注意钩子不会自动存数据,所以你还是需要一个私有字段来保存真实值。如果不搞一个底层存储,你在 set 钩子里直接操作 $this->x 就会无限重复调用 set,直接栈溢出。我第一次试的时候,差点把电脑搞重启。
二、Money 值对象:需求也很简单
一个 Money 类需要包含金额和币种。关键约束有两条:金额不能是负数,币种必须是合法的 ISO 代码。外部拿到这个对象后,希望可以读币种和金额,但不希望随意修改金额。如果非要改,就得通过一个明确的业务方法,比如增加金额或者扣除金额。
传统写法是 private 属性 + getter,再在构造方法里校验。现在有了属性钩子,我直接把公共属性暴露出来,但用不对称可见性让外部只能读,不能写。内部的构造函数(或内部方法)却可以直接赋值,并自动触发钩子校验。这样代码看起来更自然,也不需要一堆方法了。
三、新语法实现:看着真舒心
直接上代码,下面就是完整的 Money 类:
<?php
declare(strict_types=1);
final class Money
{
public private(set) int $amount {
get => $this->_amount;
set (int $value) {
if ($value < 0) {
throw new InvalidArgumentException('金额不能为负数');
}
$this->_amount = $value;
}
}
public private(set) string $currency {
get => $this->_currency;
set (string $value) {
// 简单模拟 ISO 4217 校验
if (!in_array($value, ['CNY', 'USD', 'EUR'], true)) {
throw new InvalidArgumentException('不支持该币种');
}
$this->_currency = $value;
}
}
private int $_amount;
private string $_currency;
public function __construct(int $amount, string $currency)
{
$this->amount = $amount; // 触发 set 钩子
$this->currency = $currency; // 触发 set 钩子
}
// 业务方法:增加金额
public function add(Money $other): Money
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('币种不一致');
}
return new self($this->amount + $other->amount, $this->currency);
}
// 业务方法:扣除金额(允许结果为0,不允许负数)
public function subtract(Money $other): Money
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('币种不一致');
}
$newAmount = $this->amount - $other->amount;
if ($newAmount < 0) {
throw new InvalidArgumentException('扣减后余额不能为负');
}
return new self($newAmount, $this->currency);
}
}
这里用到了 public private(set),意思是该属性可以公开读取,但赋值只能在类内部进行。从外部来看,你就不能直接写 $money->amount = 999 了,这样的意图特别明确:金额只读,但内部可以修改。
构造方法里直接给属性赋值,就会触发对应的 set 钩子,从而完成校验。你不用再去写两个 setter 方法,代码干净了一大半。
四、实际用起来感觉如何
把 Money 类接入订单金额计算中,使用体验非常顺滑:
$price = new Money(1999, 'CNY');
$shipping = new Money(0, 'CNY');
$total = $price->add($shipping);
echo $total->amount; // 1999
echo $total->currency; // CNY
// 下面这行会直接报 Error: Cannot modify private(set) property
$total->amount = 1;
这种约束比依赖私有属性强得多。以前如果只提供 getAmount(),外部还是会去寻找 setAmount() 或者反射来破坏不变性。现在通过语言层面限制了赋值行为,连反射都会失败(除非你手动改变可见性)。
五、几个必须注意的坑
1. 所有赋值都会走 set 钩子
这意味着在构造函数中赋值也会校验。如果 $amount 传负数,构造函数直接抛异常。这是一个好事,杜绝了通过构造传入非法状态。
2. 不要在 set 钩子内部修改同一个属性
千万不能写 $this->amount = $value,这会引发循环调用。必须像上面一样,在 set 钩子里把数据存储到私有字段 $_amount。
3. 不要忘了不对称可见性的边界
private(set) 意味着只有当前类内可以写。如果想从父类或 trait 中赋值?那也不行。但可以使用 protected(set) 来允许子类修改。我在设计领域对象时,经常用 protected(set) 让子类可以调整状态,但保持对外只读。
4. 序列化注意字段泄漏
因为真实值存在 _amount 和 _currency 里,json_encode 会把这两个下划线前缀的字段也带出去。为了避免这种情况,最好实现 JsonSerializable 接口,或者用 __serialize 方法控制序列化字段。
public function jsonSerialize(): array
{
return [
'amount' => $this->amount,
'currency' => $this->currency,
];
}
这样前端拿到的 JSON 就干净了。
六、属性钩子还能玩出什么花
除了上面这种保留底层存储的用法,属性钩子也可以当做只读计算属性,不用建私有字段。比如我想在 Money 里加一个 “格式化字符串” 属性:
public string $formatted {
get => $this->currency === 'USD' ? '$' . number_format($this->amount / 100, 2) : '¥' . number_format($this->amount / 100, 2);
}
这里就不需要额外的存储字段了。每次访问 $money->formatted 都动态计算。这比写一个方法可读性好得多,而且它天然是只读的,因为没有 set 钩子。
再比如,你也可以用 set 钩子实现“变更后自动更新其他字段”的副作用,比如把金额的美元版本同步修改,或者记录最后修改时间。不过这种副作用要谨慎,容易让行为隐晦。
七、值对象还是用 readonly 吗?
PHP 8.2 引入了 readonly 属性,也可以实现不可变对象。那和属性钩子的区别在哪?readonly 类只能初始化一次属性,无法提供业务方法修改状态。比如你希望 Money 的 add() 方法返回一个新的对象,那用 readonly 没问题。但如果你是想在内部对金额做一些运算并重新赋值,readonly 就不行了。属性钩子更灵活,它让你看起来是公共属性,但实际上可以控制所有赋值行为,并且能保持内部的动态更新。
比如我可以为内部的某个临时变量赋值多次,只要不违反业务规则。readonly 做不到这一点。
八、总结与个人感受
我越来越觉得,属性钩子是 PHP 向更现代语法演进的一大步。它把“属性”本身的逻辑内聚到属性声明旁边,而不是散落在 getter/setter 方法中。Money 类只是牛刀小试,如果你有一堆 DTO 或者值对象,这个特性绝对能帮你删掉不少重复代码。
但要记住,钩子只是语法糖,底层的责任边界还是要自己把握。不要在 set 钩子里做太多无关的事,保持逻辑可预测。
最后建议:如果你的项目 PHP 版本还停在 8.2 或 8.3,赶紧准备升级吧。8.4 带来的东西比我想象中带劲。
以上代码都在 PHP 8.4 RC 中实际跑过,可以看到输出符合预期。如果你也准备试用,建议开启 strict_types,配合静态分析工具一起用,效果更佳。

