去年线上出过一个跟金额有关的 bug。订单对象里 $this->amount 存的是元,一位同事在退款流程里写的是分,结果退出去 100 倍。事后复盘,问题不在算错,在于那个字段有一个公开的 setter:setAmount(),任何地方都能往里塞任何数字,单位、精度、正负号全靠调用方自觉。我们后来在 setAmount 里加了几行校验——不能是负数、最多两位小数、不能超过十万——线上是稳了,但那段校验代码散落在四个不同的商品模型里,每个都长得差不多,改一个忘三个。
PHP 8.4 引入的属性钩子(property hooks)正是冲这类问题来的。它不是语法糖,因为它把「读写一个字段」变成了可编程的行为,并且这个行为绑定在字段上,而不是绑定在一个可能被绕过的 setter 方法上。同事想 $order->amount = 100 的时候,无论他有没有想起调用 setter,校验都会跑。
这篇文章我用一个金额值对象和一个订单模型,把属性钩子从语法到踩坑都过一遍。中间有五处坑是我自己调试出来的,网上讲得少。
先看老写法哪里不对
用 setter 的写法长这样:
class Money
{
private int $cents;
private string $currency = 'CNY';
public function setCents(int $cents): void
{
if ($cents < 0) {
throw new InvalidArgumentException('金额不能为负');
}
$this->cents = $cents;
}
public function getCents(): int
{
return $this->cents;
}
public function setCurrency(string $currency): void
{
if (!in_array($currency, ['CNY', 'USD', 'EUR'], true)) {
throw new InvalidArgumentException('不支持的币种');
}
$this->currency = $currency;
}
}
这段代码本身没错,但它有三个隐性的问题。
一,字段名和访问方法名脱节。内部叫 $cents,对外叫 setCents。同事写新代码时可能直接改 private 属性——PHP 里这在同一个类的实例间是允许的——于是校验被绕过。这种情况在 code review 里很难发现。
二,赋值和校验分离。$money->setCents(500) 看起来是一次普通调用,看不出这里有会抛异常的逻辑。IDE 也不会在赋值的时候给你任何提示。
三,序列化、克隆、json_encode 一律不触发 setter。这个是最坑的。你从数据库反序列化一个对象,字段能直接写进去;你 clone 一个对象,克隆体里的字段是从源对象复制的,不经过 setter;你反序列化 JSON,也是直接写属性。你写了半天的校验,认真看一下覆盖了多少种赋值路径?可能只有一两种。
属性钩子把这三条一次性解决了:字段本身可编程,所有读写路径都走钩子。
属性钩子的基本写法
一个最简单的例子,自动把邮箱转小写:
class User
{
public string $email {
set (string $value) {
$value = strtolower(trim($value));
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('邮箱格式不对: ' . $value);
}
$this->email = $value;
}
}
}
语法要点:get 和 set 是两个钩子,写在花括号里。set 钩子里可以不写参数名,PHP 会自动提供一个叫 $value 的隐式变量;写了参数的话也能用类型声明限制传入类型。
钩子可以简写。只有一句表达式时用箭头语法:
public string $email {
set => strtolower(trim($value));
}
注意箭头语法里没有 return,表达式的值会被隐式赋给字段。get 钩子的箭头语法里则必须出现一次 return:
public string $upper {
get => strtoupper($this->name);
}
用钩子重写 Money
回到开头那个金额对象:
class Money
{
public private(set) int $cents {
set (int $value) {
if ($value < 0) {
throw new InvalidArgumentException('金额不能为负');
}
if ($value > 10_000_000) {
throw new InvalidArgumentException('单笔金额超过十万');
}
$this->cents = $value;
}
}
public private(set) string $currency = 'CNY' {
set (string $value) {
$value = strtoupper($value);
if (!in_array($value, ['CNY', 'USD', 'EUR'], true)) {
throw new InvalidArgumentException('不支持的币种: ' . $value);
}
$this->currency = $value;
}
}
public function __construct(int $cents, string $currency = 'CNY')
{
$this->cents = $cents;
$this->currency = $currency;
}
}
这段代码里有几件事同时发生了。
第一,读写路径统一。无论调用方是构造函数里赋值、外部直接 $m->cents = 500、还是 clone、还是 unserialize(),只要走的是属性写入,set 钩子都会跑。这解决了我一开始列举的前两个问题。
第二,public private(set) 是 PHP 8.4 的另一项特性——非对称可见性。这条声明读作「读是公开的,写是私有的」。也就是说外部可以 echo $m->cents,但 $m->cents = 100 会报 Error: Cannot modify private(set) property。写权限只保留给类内部。
这一步直接干掉了「setter 被忘记调用」的问题:外部代码根本没有写入口。要改金额,必须走一个显式的方法,比如 $money->add(new Money(500)),而这个方法内部会走钩子经过校验。三种赋值路径——直接写、序列化、克隆——全都在控制之下了。
非对称可见性和 setter 的区别
有人可能会说,public private(set) 不就是一个变相的 getter 吗?有一点像,但差别很大。
getter 是一个方法,你可以在任何时候给它改成别的方法名、加参数、返回别的类型。接口里如果定义 getter,别人实现的时候要么覆盖它,要么在父类里拷贝一份。public private(set) 是语言的属性修饰符,它在类型系统层面告诉你:这个字段对外只读。IDE 会立刻标红,静态分析工具会报警,运行时也会抛异常。
更重要的是,你可以为这个只读字段加一个 get 钩子:
public private(set) string $display {
get => $this->currency === 'CNY'
? '¥' . number_format($this->cents / 100, 2)
: '$' . number_format($this->cents / 100, 2);
}
这是一个虚拟属性——没有存储,每次读都现算。外部看到的是 $money->display,读出来是 ¥12.34。如果没有 get 钩子,你得写一个 getDisplay() 方法,然后全项目都得记着用这个方法名。
虚拟属性:看起来像字段,其实在计算
虚拟属性是属性钩子最实用的部分。它指的是没有后备存储、完全由钩子产生的属性。判断标准很简单:钩子里引用了 $this->属性名 的就有存储,没引用的就是虚拟属性。
订单模型里加一个总金额:
class Order
{
/** @var OrderItem[] */
public private(set) array $items = [];
public private(set) int $totalCents {
get {
$sum = 0;
foreach ($this->items as $item) {
$sum += $item->subtotalCents;
}
return $sum;
}
}
public private(set) string $summary {
get => sprintf('%d 件商品,合计 %.2f 元',
count($this->items),
$this->totalCents / 100
);
}
}
调用方使用的方式和读普通属性一样:$order->totalCents。但它永远是最新的,不需要手动调用 recalc() 之类的同步方法。你也不用担心有人忘记更新它。
$items 有 public private(set),意味着外部不能直接 $order->items = [...] 覆盖它,只能通过 addItem 之类的方法,这也是我们想要的。
坑一:虚拟属性不能有 set 钩子
这个坑我第一天就踩了。给 $totalCents 写了一个 set 钩子想着方便测试时直接赋值:
public int $totalCents {
get => array_sum(array_map(fn($i) => $i->subtotalCents, $this->items));
set => $this->totalCents = $value; // 这行会死循环
}
PHP 会直接报错:「Cannot define a set hook on a virtual property」。因为虚拟属性没有后备存储,你没法「写」它。只能有 get。
测试里想造数据的话,直接构造真实的 items 数组即可,不要走捷径。这也算是一种提醒:如果某个字段你发现想给它写一个 set 钩子,说明它不是真正的虚拟属性。
坑二:接口里的属性钩子只能用 get
PHP 8.4 允许在接口里声明属性钩子,但有个限制——接口只能要求 get,不能要求 set。也允许 get 和 set 都声明,但 set 在接口里的表达力有限。
interface HasLabel
{
// 只要求有 get,实现者自己决定后备存储和写权限
public string $label { get; }
}
class Product implements HasLabel
{
public private(set) string $label {
get => $this->name . ' (' . $this->sku . ')';
}
}
这个限制挺合理的。如果接口能要求 set,那么每个实现类都得提供一个可写入口,那非对称可见性就没法用了。设计师是故意留出这个口子的。
坑三:克隆时钩子不会重跑
clone 的语义是浅拷贝。克隆出来的对象,它的非对象属性直接从源对象复制,不走 set 钩子。这意味着如果你在 set 钩子里做了副作用——写日志、更新计数、触达另一个对象——克隆完之后,那些副作用不会为克隆体重跑。
一般情况下这是对的行为,因为你不想克隆一次就发一封邮件。但有两个例外要考虑。
第一个例外是唯一 ID 字段。很多项目里会用 uniqid() 生成订单号,写在 set 钩子里。克隆之后,两个对象共享同一个 ID:
class Order
{
public private(set) string $no {
set (string $value) {
// 只在为空时生成一次
$this->no = $value ?: $this->generateNo();
}
}
public function __clone()
{
// 克隆时清空订单号,重走一次生成
// 但不能直接 $this->no = '',那样会命中"为空时生成"
// 得先绕开钩子
$this->regenerateNo();
}
private function regenerateNo(): void
{
// 直接操作后备存储,绕过 set 钩子
// 用 unset 可以让下次访问走一遍钩子初始化
unset($this->no);
}
}
这里要解释一下 unset($this->no) 的效果。对于有 set 钩子的属性,第一次被读时如果后备存储为空,PHP 会调用 set 钩子。所以 unset 之后,下次 $this->no 或者 $this->no = '' 都会重新走一遍生成逻辑。
第二个例外是引用类型字段。如果你有一个 $tags 数组属性,克隆出来的两个对象会共享同一个数组引用吗?不会。PHP 的数组是值语义,浅拷贝会把数组复制一份,两个对象各自拥有独立的数组。这点不用担心。但如果数组里装的是对象,两个数组会共享同一个对象——那是另一回事,得写 __clone 手动深拷贝。
坑四:序列化和钩子没有关系
很多人以为序列化会触发 get 钩子,反序列化会触发 set 钩子。不是的。
serialize() 和 unserialize() 走的是 PHP 内部的属性表,不经过钩子。json_encode 也一样,它读的是后备存储,虚拟属性会被直接忽略掉。
这个行为跟我们想要的可能不一样。比如说,你希望订单被 json 序列化时带上前面的 totalCents 和 summary:
$json = json_encode($order);
// 结果里没有 totalCents,也没有 summary
// 因为它们是虚拟属性,没有后备存储
解决办法是实现 JsonSerializable:
class Order implements JsonSerializable
{
public function jsonSerialize(): array
{
return [
'items' => $this->items,
'totalCents' => $this->totalCents,
'summary' => $this->summary,
];
}
}
这是用钩子时的一句硬性提醒:如果你依赖 json_encode 输出虚拟属性,就必须实现 JsonSerializable。没有别的办法。
同样的道理,var_export() 和 print_r() 也只读后备存储,虚拟属性在调试输出里是不可见的。这一点其实挺好的——调试时你会立刻意识到那些是不存在的字段。
坑五:父类和子类的钩子关系
如果子类要覆写父类的 get 钩子,同时还想用父类的逻辑,得用 parent:: 语法,写法跟调用父类方法类似:
class DiscountedOrder extends Order
{
public private(set) int $totalCents {
get => (int) round(parent::$totalCents * 0.9);
}
}
注意 parent::$totalCents 这种写法——属性访问符还是属性访问符,只是前面加上了 parent::。这个语法我第一眼看的时候觉得有点怪,写两次就习惯了。
需要注意的是,属性钩子不能单独被覆写一个。如果子类覆写了 totalCents 的 get 钩子,那么它的 set 钩子(如果有的话)也要一起声明,否则父类的 set 会被继承下来。对于虚拟属性这种情况没法发生——虚拟属性本来就只有 get——但对于有存储的属性要小心。
另外,父类如果用了非对称可见性 public private(set),子类覆写时不能放宽也不能收紧,必须保持一致。子类想反向操作——比如让它变得外部可写——PHP 会报错。
性能上值不值得
我拿一个简单的 benchmark 大概跑了一下:一个带 set 钩子校验的整数字段,赋值一百万次,跟直接写公开属性比,慢大约 15%。这个数字挺小的,因为钩子本身是被编译进 opcode 的,不用走一次方法调用。
真正影响性能的是虚拟属性里做了什么。比如 $totalCents 里遍历 items 数组,每次读都是 O(n)。如果一个请求里读了它十次,那就是遍历十次。这种地方我一般会加一个手动缓存:
class Order
{
private ?int $cachedTotal = null;
public private(set) int $totalCents {
get {
if ($this->cachedTotal === null) {
$sum = 0;
foreach ($this->items as $item) {
$sum += $item->subtotalCents;
}
$this->cachedTotal = $sum;
}
return $this->cachedTotal;
}
}
public function addItem(OrderItem $item): void
{
$this->items[] = $item;
$this->cachedTotal = null; // 让缓存失效
}
}
看起来啰嗦,但这一块的写法跟不用钩子时是一样的——你总得在某个地方做缓存失效,只是以前那个地方叫 setter,现在叫 addItem。
什么时候别用
三种情况我会刻意避开属性钩子。
第一,字段名和方法名对不上的场景。$user->getName() 和 $user->name 语义上确实有差别:前者是一个动作,后者是一次数据读取。如果你要表达的是「获取」而不是「读取」,比如从缓存里取、从数据库查、从 API 拉,这种语义差异会让人懵。用钩子的时候读一个虚拟属性可能触发一次 I/O,外人看不出来,这是我没法接受的隐式成本,所以这类场景我还是写方法。
第二,需要参数化的访问。比如 $money->roundedTo(2)、$order->itemsByStatus('paid')。参数化的东西天生就该是方法。
第三,追求极致性能的热路径。ORM 的实体映射层、每秒几万次的请求处理循环,这些地方能省一次函数调用就省一次。属性钩子的开销虽小,但在这些场景里能成为累加项。
最后一个诚实的建议。如果你维护的是 PHP 8.3 及以下的项目,不要为了用钩子升级到 8.4。升级带来的兼容性排查成本,远超过省下 getter/setter 的收益。等下一次有其他必须升级的理由时,顺手把这块重构了比较划算。
收尾
回到开头那个金额 bug。改造完之后,同事的那行代码变成了:
// 之前
$refund->setAmount(100);
// 之后
$refund->amount = new Money(100, $refund->currency->value);
看着更长了,但错误不可能发生。单位藏在 Money 这个类型里,币种必须显式给出,校验在构造时通过钩子完成。如果同事真的写错单位,编译器会先报错——因为 100 是 int,不是 Money。
这就是属性钩子真正值钱的地方。它让你能够用类型系统表达「金额不是数字」,而这个表达在以前只能用文档和 code review 来维护。
如果你现在打开项目,发现同样的校验逻辑在四五个类里重复出现,那就从那一处开始练手。先用钩子把校验搬进字段,再加上非对称可见性关掉外部的写入口。改造过程中会碰到序列化和克隆那几个坑,文章里都列了处理方式。真正花时间的不是写钩子,是发现哪些地方原来在偷偷直接写私有属性——这一步往往能揪出几个陈年老 bug。

