PHP 8.4 在 2024 年 11 月正式发布,Property Hooks 是三件大事之一(另外两件是新的 array 函数和不对称可见性)。发布之后我一直在找机会用上它,但业务代码改起来涉及面太大,一直没敢动。
年底项目组做复盘的时候,负责订单模块的同事吐槽了一句:”Order 这个类的 getter 已经 500 行了,我看一眼就想关掉编辑器。”我当时就决定,把这块拿来练手。
这篇文章记录的就是重构的全过程,包括几个我一开始没预料到的坑。如果你正准备把项目升到 PHP 8.4,或者只是想看看 property hooks 到底能干什么、不能干什么,可以往下看。
老代码到底长什么样
Order 类核心是四个字段:订单金额、优惠金额、税率、应付总额。围绕这四个字段,有十几组 getter/setter 和七八个派生方法。精简之后大概是这样:
final class Order
{
private int $amountCents = 0;
private int $discountCents = 0;
private float $taxRate = 0.06;
public function getAmount(): float
{
return $this->amountCents / 100;
}
public function setAmount(float|int $amount): void
{
if ($amount < 0) {
throw new InvalidArgumentException('订单金额不能为负');
}
$this->amountCents = (int) round($amount * 100);
}
public function getDiscount(): float
{
return $this->discountCents / 100;
}
public function setDiscount(float|int $discount): void
{
if ($discount < 0) {
throw new InvalidArgumentException('优惠金额不能为负');
}
if ($discount > $this->getAmount()) {
throw new InvalidArgumentException('优惠金额不能超过订单金额');
}
$this->discountCents = (int) round($discount * 100);
}
public function getPayable(): float
{
$base = $this->amountCents - $this->discountCents;
return $base / 100;
}
public function getTax(): float
{
$base = $this->amountCents - $this->discountCents;
return round($base / 100 * $this->taxRate, 2);
}
public function getTotal(): float
{
return $this->getPayable() + $this->getTax();
}
// ... 其他十几个派生方法(展示字段、导出字段、日志字段)
}
痛点在哪儿?
一是”金额”这个概念有两种表达——元(float)和分(int)。所有 getter 都是元,所有数据库写入都用分,业务层调用方经常要除以 100 或乘以 100,一不小心就少一位。
二是校验散在 setter 里,但 taxRate 没有 setter,是通过构造函数注入的公有属性,改它完全不受控制。
三是派生方法(getPayable、getTax、getTotal)都是”查一次算一次”,如果调用方在一个方法里连用三次 getTotal,就要算三次。
改造目标很明确:让所有金额都走同一套规则,让派生值像属性一样访问,让外部只能读不能写。
先别急着改,先草拟新结构
我在纸上画了一遍新结构,思路是这样的:
四个字段对外全部用 hook 暴露,内部 backing store 保留分的整数表示。金额和优惠的 set hook 里做校验并转换。派生字段(应付、税费、总额)用虚拟属性——只写 get hook,不写 set hook,也没有 backing store。所有 public 属性加 private(set) 让外部只读。
草拟完之后我先写了一个最小可用的 demo 在 REPL 里跑通,确认语法和语义都能对得上,再动真代码。
基础:Property Hooks 写法速览
先看一个最简单的例子。以前写 setter 是这样的:
private string $name = '';
public function setName(string $name): void
{
$this->name = trim($name);
}
public function getName(): string
{
return $this->name;
}
现在可以压成一段:
public string $name = '' {
set => trim($value);
}
这里有个语法设计值得注意:set => trim($value) 中的表达式返回值,会作为存入 backing store 的最终值。等价于手写:
public string $name = '' {
set {
$this->name = trim($value);
}
}
两种写法在你需要多行逻辑(比如抛异常)的时候才用大括号,一行表达式用简写更干净。
get hook 的写法类似:
public string $formattedName {
get => strtoupper($this->name);
}
这个只写了 get 的属性就是虚拟属性——它没有 backing store,每次读的时候实时计算。虚拟属性有个硬性限制:不能有默认值,因为没地方存。如果你写 public string $formattedName = '' { get => ... } 会直接报语法错误。
重构第一步:金额字段的 hook 化
改造之后的 amount 字段大致长这样:
public private(set) int $amountCents = 0 {
set (int|float $value) {
$cents = is_float($value)
? (int) round($value * 100)
: $value * 100;
if ($cents < 0) {
throw new InvalidArgumentException('订单金额不能为负');
}
$this->amountCents = $cents;
}
}
三个点解释一下。
类型声明用了 int|float。因为调用方可能传 19.9,也可能传 1990(分)。收窄类型到 int 会强制调用方自己转换,反而让业务层到处都是 * 100。
backing 存”分”。浮点数做 money 计算是灾难,我们项目里所有和钱相关的内部存储都是分的整数。hook 里做一次转换,外部看到的仍然是元。
set hook 里给 $this->amountCents 赋值。这一点跟 get hook 不同,是允许的,而且不会递归——因为 set hook 里对同名属性的写入语义就是”写入 backing store”。
然后discount 类似,但多一条跨字段校验:
public private(set) int $discountCents = 0 {
set (int|float $value) {
$cents = is_float($value)
? (int) round($value * 100)
: $value * 100;
if ($cents < 0) {
throw new InvalidArgumentException('优惠金额不能为负');
}
if ($cents > $this->amountCents) {
throw new InvalidArgumentException('优惠金额不能超过订单金额');
}
$this->discountCents = $cents;
}
}
这里我停下想了一会儿:如果先设置了 discount,后设置 amount,那么 amount 变小之后 discount 就可能非法。老代码里也存在这个问题,但因为是 setter 方法,调用方有”顺序意识”。改成 hook 之后属性访问看起来太轻量,反而更容易踩雷。
最终我在 amountCents 的 set hook 里补了一条反向校验:如果新金额小于已有优惠,直接抛异常。这样两个字段无论谁先设置,都能保持一致性。
第二步:派生值用虚拟属性表达
应付、税费、总额这三个字段老代码是 getPayable()、getTax()、getTotal(),调用方要打成方法调用。改成虚拟属性:
public float $payable {
get => ($this->amountCents - $this->discountCents) / 100;
}
public float $tax {
get => round(
($this->amountCents - $this->discountCents) / 100 * $this->taxRate,
2
);
}
public float $total {
get => $this->payable + $this->tax;
}
这样调用方写 $order->total 就跟读普通属性一样,代码比 $order->getTotal() 干净很多。同时因为是虚拟属性,外部不能写,天然就是只读的,不需要在 set hook 里抛异常拦。
注意 total 那个虚拟属性内部又调用了 $this->payable 和 $this->tax,这两个也是虚拟属性,每次读都会重新计算。如果性能敏感,可以考虑缓存一份,但我们的交易场景调用频率不高,直接计算就够。
第三步:不对称可见性
PHP 8.4 新增的 public private(set) 修饰符,允许属性的读取用 public,写入用 private。
为什么这个对我们重要?原来 taxRate 是个 public 属性,外部随便改,改完了还不动声色的。现在改成:
public private(set) float $taxRate {
set (float $value) {
if ($value < 0 || $value > 1) {
throw new InvalidArgumentException('税率必须在 0 到 1 之间');
}
$this->taxRate = $value;
}
}
外部可以读 $order->taxRate,但尝试写会直接 fatal error(”Cannot modify private(set) property”)。只有类内部能调整税率。
这个语法比我原先想的要贴合实际。老代码里经常有一堆 public 属性最后被业务层各改各的,加了 private(set) 之后这些字段终于有了明确的所有权。
坑一:get hook 里读同名属性会无限递归
这是我踩的第一个坑,也是最容易踩的一个。
重构 amount 的虚拟展示属性时,我一开始是这么写的:
public string $amountText {
get => number_format($this->amount / 100, 2);
}
这里的 $this->amount 是谁?当时我以为不用管,因为类里没有 amount 属性——我在重构中把 amount(浮点)改成了 amountCents(整数)。但 IDE 没报错,php -l 也没报错。
直到运行时才发现:$this->amount 不存在,PHP 会用动态属性或者触发 __get(如果定义了)。我们类里没写 __get,所以直接返回 null,然后 number_format(null / 100, 2) 得到 "0.00"。
比这更危险的是:如果我把属性名写成和虚拟属性同名会怎样?
public float $payable {
get => $this->payable * 2; // 无限递归
}
这个才是真的炸——get hook 里读同名虚拟属性会递归触发自己,栈溢出直接 fatal error。
PHP 8.4 里没有语法阻止这种情况,只能靠命名规范。我最终的规范是:backing store 一律加 Cents 后缀,虚拟属性一律不加;虚拟属性内部严禁引用同名标识符。
坑二:backing value 在 get hook 里拿不到
这个坑更隐蔽,因为它涉及一个我完全没预料到的语义空缺。
我需要一个 “amountCents 转成元,但保留两位小数的展示” 属性。直觉写法是:
public float $amount {
get => $this->amountCents / 100;
}
这个能跑,因为 amountCents 是个真实属性,没有 hook——读它就是读 backing。
但如果我对 amount 加了 set hook(在上面第一个坑的代码里我就这么做了),同时又要提供 get hook 来返回元,那么:
public int|float $amount {
get => $this->amount / 100; // 递归
set => $this->amount = $value * 100; // 写入 backing 但读还是递归
}
这种情况PHP 8.4 没有提供任何”访问本属性 backing value”的语法。动态属性、魔术方法、反射都解决不了这个问题——只要属性标识符相同,get 就是递归。
唯一的解法是把 backing 移到另一个名字的属性上,这就是为什么最后我们用了 amountCents。这个设计选择不是为了语义清晰,是被迫的。官方 RFC 里明确说了这个语义,但一开始不会联想到自己会撞上。
坑三:反射会触发 hook
这个坑出现在我们上线之后。
订单落库之前有个”审计”环节,它会用 ReflectionClass 遍历所有属性导出快照。重构上线当天,这个审计任务开始大量报错:
InvalidArgumentException: 优惠金额不能超过订单金额
找了好久才发现问题。ReflectionProperty::setValue 在 PHP 8.4 里会触发目标属性的 set hook——不像以前那样直接写内存。而我们的快照逻辑是先反射拿到属性,然后 setValue 一个从数据库读出来的值——也就是把整个对象的值拷到新对象上。
但审计用的是一个空壳对象(new Order()),此时 amountCents 还是 0。虽然 discountCents 从数据库读出来是 100,但 setValue 触发 set hook,set hook 里检查 $cents > $this->amountCents 也就是 100 > 0,直接抛异常。
处理方式有两种。一是审计代码改成顺序赋值(先 amount 后 discount);二是可以在 hook 里用 flag 绕开校验。我选了第一种,因为审计快照本来就该按对象的合法状态构建,顺序赋值是合理的。但如果你的场景涉及更复杂的反射用法(比如 ORM 的字段填充),可能就要准备个 setWithValidation(bool $enable) 之类的开关。
这一条给我们的教训是:hook 把校验做进属性赋值里,任何依赖反射或反序列化的地方都可能被这套校验误伤。
坑四:虚拟属性不能被遍历序列化
这个坑不致命但很烦。
我们有个 toArray() 方法,本意是把订单导出成数组给前端。重构之前实现很简单:
public function toArray(): array
{
return [
'amount' => $this->getAmount(),
'discount' => $this->getDiscount(),
'payable' => $this->getPayable(),
'tax' => $this->getTax(),
'total' => $this->getTotal(),
];
}
重构完后想偷懒,改成:
public function toArray(): array
{
return get_object_vars($this);
}
跑了一下发现虚拟属性一个都没出来。get_object_vars 只返回真实存在的属性(backing store),虚拟属性不在其中。改回来手写 ['amount' => $this->amount, ...],问题解决。
如果用的是 json_encode 或者 (array) $obj,实测行为也差不多——虚拟属性不参与。这其实是个设计上的优点,因为虚拟属性是”计算出来的”、不是”存储的”,不参与自动序列化可以避免很多混乱。但前提是你得记住这件事。
如果确实需要序列化虚拟属性,加一个 JsonSerializable 接口实现 jsonSerialize(),手动列出所有要导出的字段。
坑五:接口里的属性声明和子类实现
PHP 8.4 有一个配套特性:接口里可以声明属性 hook 的契约。
interface MoneyView
{
public float $payable { get; }
}
意思是任何实现 MoneyView 的类,都必须有 $payable 属性,且至少支持读取。
我们想用这个来约束多个订单类型的字段。但实际动手时发现子类实现方式很受限:
如果子类用普通属性 public float $payable = 0; 实现,虽然满足”可读”要求,但同时也允许外部写入——这和接口的本意(只读)有出入。而如果用虚拟属性 public float $payable { get => ...; },则可以完美匹配。
最后我决定:接口里的属性契约统一用虚拟属性实现,如果某个子类需要动态值,就写成 get hook;如果不需要动态,就用不对称可见性 public private(set) 加 get hook。
这个约束并不由编译器强制,只能靠代码评审。但至少接口声明把”这个字段存在且可读”这件事变成了编译期约定,比之前靠注释或父类抽象方法要好很多。
坑六:hook 的性能开销
重构完的第二天,QA 压测报了一个数据:某个走 Order->total 的高频接口 P99 涨了 3ms。
我第一反应是”不可能吧,本来就是个几微秒的操作”。做了基准测试:
$o = new Order(); // 一笔正常订单
$N = 1_000_000;
$t = hrtime(true);
for ($i = 0; $i < $N; $i++) {
$x = $o->amountCents; // 直接属性访问
}
$direct = (hrtime(true) - $t) / 1e9;
$t = hrtime(true);
for ($i = 0; $i < $N; $i++) {
$x = $o->payable; // 虚拟属性(含两次属性读 + 一次减法)
}
$virtual = (hrtime(true) - $t) / 1e9;
printf("direct: %.4fs, virtual: %.4fsn", $direct, $virtual);
跑了三遍取平均(PHP 8.4.2,普通 cloud 环境):
直接属性访问 100 万次耗时约 0.04 秒,虚拟属性访问 100 万次耗时约 0.35 秒。差了将近 10 倍,单次开销从 40ns 涨到了 350ns。
绝对数字看其实微不足道,350ns 对比一次 SQL 查询的几毫秒就是零头。但问题在于如果有循环里反复访问虚拟属性,或者一页数据里每条记录都要 toArray(),那开销就会放大。
我们的处理方式:在 P99 涨的那个接口里,把 $order->total 访问改成了提前取出一次存局部变量。这样在循环里就不用反复算了。
至于那个虚拟属性本身,我们没打算改成 backing store,因为”实时计算”就是它存在的意义。要做优化,应该在调用方做,不该在定义方做。
整体效果对比
重构之后的完整类贴在下面(字段简化了一部分):
final class Order
{
public private(set) int $amountCents = 0 {
set (int|float $value) {
$cents = is_float($value) ? (int) round($value * 100) : $value * 100;
if ($cents < 0) {
throw new InvalidArgumentException('订单金额不能为负');
}
if ($cents < $this->discountCents) {
throw new InvalidArgumentException('新金额小于已设置的优惠');
}
$this->amountCents = $cents;
}
}
public private(set) int $discountCents = 0 {
set (int|float $value) {
$cents = is_float($value) ? (int) round($value * 100) : $value * 100;
if ($cents < 0) {
throw new InvalidArgumentException('优惠金额不能为负');
}
if ($cents > $this->amountCents) {
throw new InvalidArgumentException('优惠金额不能超过订单金额');
}
$this->discountCents = $cents;
}
}
public private(set) float $taxRate = 0.06 {
set (float $value) {
if ($value < 0 || $value > 1) {
throw new InvalidArgumentException('税率必须在 0 到 1 之间');
}
$this->taxRate = $value;
}
}
public float $payable {
get => ($this->amountCents - $this->discountCents) / 100;
}
public float $tax {
get => round(
($this->amountCents - $this->discountCents) / 100 * $this->taxRate,
2
);
}
public float $total {
get => $this->payable + $this->tax;
}
public string $totalText {
get => number_format($this->total, 2);
}
public function toArray(): array
{
return [
'amount' => $this->amountCents / 100,
'discount' => $this->discountCents / 100,
'payable' => $this->payable,
'tax' => $this->tax,
'total' => $this->total,
'totalText' => $this->totalText,
];
}
}
老代码大约 500 行(含大量派生方法和注释),新代码 70 多行公共 API。功能没有减少。调用方的代码也变短了:
// 老代码
echo $order->getTotal();
$order->setAmount(99.9);
$order->setDiscount(10);
// 新代码
echo $order->total;
$order->amountCents = 99.9;
$order->discountCents = 10;
注意 amountCents 这个名字有点丑,语义上应该叫 amount。改名的代价是要么放弃 hook 化(纯 backing),要么放弃”元/分自动换算”。我们最终妥协了:amountCents 语义明确但外部写入时仍然按”元”接受——因为 hook 会换算。
这个命名妥协值得单独说一下:hook 化之后,属性名的语义有时会变得模糊,因为名字指的是”外部看到的概念”,而底层存的内容可能不是同一个单位/格式。amountCents 精确但它不代表外部视角。真要严格,应该叫 amount,backing 叫 amountInCents,可这样就违背了上一节坑二说的机制了。
哪些 getter 不该改
不是所有 getter 都值得换成 hook。这段时间下来我总结了几条标准。
适合改的:属性语义简单、校验逻辑固定(非负、非空、范围)、派生值只依赖同类里的其他属性、不需要访问外部资源。
不适合改的:
一是需要访问数据库或外部服务的属性。把 SQL 查询塞进 hook 里,属性访问就变成了不可预期的慢操作,用的人容易踩坑。这种情况还是老样子写个 loadXxx() 或 fetchXxx() 方法,明确表达”这会触发 IO”。
二是有副作用的 getter。老代码里偶尔有 getXxx() 里插日志或者更新缓存的行为,这类绝不能用 get hook 重写——因为虚拟属性一旦被访问就会触发,调用方根本不知道会有什么后果。
三是复杂的多字段联动。如果 set 某个字段要同时更新另外三个字段,那大概率不适合用 hook,因为这样的操作更像一次”业务动作”而不是”属性赋值”。可以保留原来的 setter 方法,只是让 hook 只负责校验。
我们还留了一个 applyPromotion(Coupon $coupon) 方法,因为应用优惠券涉及”计算 + 校验 + 记录日志”多个动作,用 hook 处理反而会隐晦化。
升级前需要确认的事
PHP 8.4 不是个”改个版本号就能上”的版本。实际动手前,有几件事必须先确认:
一是序列化库的兼容性。Symfony Serializer、JMS Serializer、Laravel 的 Eloquent 都涉及反射读写属性。前面讲的”反射触发 hook”那一坑就是因为这个。升级前跑一遍完整测试,尤其是涉及对象反序列化、审计快照、批量导入的用例。
二是静态分析工具的版本。PHPStan 从 2.x 开始完整支持 hook 的类型推断。如果你的项目还在 PHPStan 1.x,会得到大量误报。Psalm 也需要跟着升级。
三是 ORM 的版本。Doctrine ORM 3.3 之后对 hook 的支持才好一些。之前如果用 ReflectionProperty 强行读属性,会触发 hook 误校验。我们项目用的是自己写的简单 ORM,反而避开了这个坑。
四是扩展。xdebug 我没试过,但听说对 hook 里下单点支持得还不太全。如果你依赖 xdebug 调试,先升级到最新版本再说。
最后
这次重构整体花了两天改代码、一天调试、一天测试。上线之后一共撞了上面六个坑,其中前两个在 demo 阶段就发现了,后面四个都是上线后才冒出来的。
现在这块代码一直在线上跑着,最近一次改动加了个优惠券字段,改动量从老代码时代的”改 4 个方法 + 补 3 处调用”变成了”加一个属性 + 改一个 toArray”。这个变化我觉得比那些具体的性能数字更值得。
但我也不建议你在项目里无脑推 Property Hooks。它是一个”让属性访问更像属性”的工具,适合用在建模清晰、职责单一的字段上。碰到复杂的业务逻辑,该写方法还是写方法。哪怕你的 getter 方法有 500 行,也不是靠 hook 来解决的最优解。
过度使用 hook 会把类变成一个隐藏着各种副作用的黑盒——因为读 $order->xxx 的时候你根本不知道内部是好几次计算还是查了一次缓存。什么时候用 hook、什么时候用方法,心里要有一条清晰的界线。

