PHP 8.4 在 2024 年 11 月发布,最令人兴奋的莫过于属性钩子(property hooks)的引入了。以前写个 DTO、实体类,满屏都是 getter/setter,又长又啰嗦。现在有了属性钩子,终于可以像写 Python/Swift 那样,直接给属性定义访问逻辑了。这篇我就拿一个真实的订单金额计算场景,把属性钩子从语法到坑讲清楚。
为什么我们需要属性钩子?
先看看老写法。假设有个订单项类,里面有数量、单价、折扣比例,最终要算总额。以前你至少要写几个方法:
class OrderItem
{
public function __construct(
private float $quantity,
private float $unitPrice,
private float $discountRate = 0.0
) {}
public function getQuantity(): float { return $this->quantity; }
public function setQuantity(float $quantity): void { $this->quantity = $quantity; }
public function getUnitPrice(): float { return $this->unitPrice; }
public function setUnitPrice(float $unitPrice): void { $this->unitPrice = $unitPrice; }
public function getDiscountRate(): float { return $this->discountRate; }
public function setDiscountRate(float $discountRate): void { $this->discountRate = $discountRate; }
public function getSubtotal(): float
{
return $this->quantity * $this->unitPrice;
}
public function getDiscountedTotal(): float
{
return $this->getSubtotal() * (1 - $this->discountRate);
}
}
这还不算过分。但项目里有十几个这样的类,那就全是模板代码了。更要命的是,你需要处理“属性变了,关联值自动更新”的逻辑,还得在 getter 里加各种判断,很容易漏。
PHP 8.4 的属性钩子允许你直接给属性挂上 get 和 set 逻辑,调用时跟访问普通属性一样,不再需要方法名。上面的代码可以重构成下面这样:
class OrderItem
{
public float $quantity;
public float $unitPrice;
public float $discountRate = 0.0;
public float $subtotal {
get => $this->quantity * $this->unitPrice;
}
public float $discountedTotal {
get => $this->subtotal * (1 - $this->discountRate);
}
}
是不是清爽多了?外部调用直接 $item->subtotal,访问属性和访问普通属性一模一样。
案例:订单金额计算器的完整实现
为了更实用,我们扩展一下:一个订单里可能有多个订单项,还要加上满减优惠、运费。我们用属性钩子把这些计算都架起来。
1. 订单项类:使用虚拟属性
class OrderItem
{
public float $quantity {
get => $this->quantity;
set => $value > 0 ? $value : throw new InvalidArgumentException('数量不能小于0');
}
public float $unitPrice {
get => $this->unitPrice;
set => $value >= 0 ? $value : throw new InvalidArgumentException('单价不能为负');
}
public float $discountRate {
get => $this->discountRate;
set {
if ($value < 0 || $value > 1) {
throw new InvalidArgumentException('折扣率必须在0~1之间');
}
$this->discountRate = $value;
}
}
// 注意:下面两个是虚拟属性,没有 backing field
public float $subtotal {
get => $this->quantity * $this->unitPrice;
}
public float $discountedTotal {
get => $this->subtotal * (1 - $this->discountRate);
}
public function __construct(
float $quantity,
float $unitPrice,
float $discountRate = 0.0
) {
$this->quantity = $quantity;
$this->unitPrice = $unitPrice;
$this->discountRate = $discountRate;
}
}
上面有几个细节值得说:
- set 钩子里可以用
$value表示传入的值。需要取原始值时,可以用$this->quantity,但要注意钩子会递归。所以在 set 钩子里给属性赋值必须是$this->quantity = $value,而读取的话…这里我直接用了get => $this->quantity会引发递归!因为 get 钩子又去读 quantity,读 quantity 又触发 get 钩子。
啊,我踩坑了。在上面的代码里,public float $quantity { get => $this->quantity; ... } 这样写是无限递归的。实际上,对于有后备属性(backing field)的属性,PHP 提供了 $this->quantity 在钩子内部代表原始存储的值,但是你要保证这个属性本身没有钩子。如果你明确写了 get 钩子,再去 $this->quantity,它还是会走 get 钩子,导致递归。
正确定义应该是:
class OrderItem
{
public float $quantity {
set {
if ($value <= 0) throw new InvalidArgumentException('数量不能小于0');
$this->quantity = $value;
}
}
public float $unitPrice {
set {
if ($value < 0) throw new InvalidArgumentException('单价不能为负');
$this->unitPrice = $value;
}
}
public float $discountRate = 0.0 {
set {
if ($value < 0 || $value > 1) throw new InvalidArgumentException('折扣率必须在0~1之间');
$this->discountRate = $value;
}
}
public float $subtotal {
get => $this->quantity * $this->unitPrice;
}
public float $discountedTotal {
get => $this->subtotal * (1 - $this->discountRate);
}
public function __construct(
float $quantity,
float $unitPrice,
float $discountRate = 0.0
) {
// 这里在构造函数中直接使用钩子赋值,会走 set 钩子逻辑
$this->quantity = $quantity;
$this->unitPrice = $unitPrice;
$this->discountRate = $discountRate;
}
}
注意:quantity 和 unitPrice 没有显式 get 钩子,因为默认读取就是返回 backing field 的值。一旦定义了 set 钩子,并且没定义 get 钩子,PHP 会保留一个隐含的 get 逻辑,直接返回原始值,所以我们在 subtotal 的 get 钩子里读 $this->quantity 是安全的,它不会递归。这是 PHP 8.4 的规则:如果一个属性定义了钩子,但缺少 get 或 set,缺失的操作按普通属性处理。
2. 订单类:把运费、满减和总额串起来
现在写订单类。满减策略:满 200 减 20;运费:不满 99 收 10 块,超过免运费。这些都写进虚拟属性里:
class Order
{
/** @var OrderItem[] */
private array $items = [];
public float $subtotal {
get {
$sum = 0.0;
foreach ($this->items as $item) {
$sum += $item->discountedTotal;
}
return $sum;
}
}
public float $discount = 0.0 {
get => $this->subtotal >= 200 ? 20 : 0.0;
}
public float $shippingFee {
get => $this->subtotal >= 99 ? 0.0 : 10.0;
}
public float $grandTotal {
get => $this->subtotal - $this->discount + $this->shippingFee;
}
public function addItem(OrderItem $item): void
{
$this->items[] = $item;
}
}
这里 discount 和 shippingFee 依赖 subtotal,而 grandTotal 又依赖它们。用属性钩子表达这种依赖关系非常直观,而且每次读取都会实时计算,不会出现“价格变了但总额缓存没更新”的问题。
3. 测试一下
$order = new Order(); $order->addItem(new OrderItem(12.5, 8.8)); // 110.0,折扣率0,小计110 $order->addItem(new OrderItem(3, 39.9, 0.1)); // 3*39.9*0.9 = 107.73 echo '小计:' . $order->subtotal . PHP_EOL; // 217.73 echo '满减:' . $order->discount . PHP_EOL; // 20 (因为≥200) echo '运费:' . $order->shippingFee . PHP_EOL; // 0 (因为≥99) echo '应付:' . $order->grandTotal . PHP_EOL; // 197.73
输出结果符合预期。你甚至可以在 set 时触发联动逻辑。比如修改一个订单项的数量后,订单的 subtotal 自动变化:
$orderItem = $order->items[0]; $orderItem->quantity = 100; // 小计立即变成 100*8.8 + 107.73 = 987.73 echo $order->grandTotal; // 987.73 - 20 + 0 = 967.73
这种“属性间自动推导”的能力,在旧写法里非常繁琐。你要靠监听器、观察者模式,或者一遍遍手动调用 getter 重算。现在一行 get 钩子全搞定。
属性钩子的关键语法要点
为了避免大家踩坑,我把语法规则和注意事项列一遍:
1. 基本形式
public string $name {
get => $this->name; // 有后备属性
set => $this->name = $value; // 可以使用 $value
}
也可以完整写法:
public string $name {
get { return $this->name; }
set {
if ($value === '') throw new Exception('不能为空');
$this->name = $value;
}
}
2. 虚拟属性(virtual property)
如果一个属性没有后备字段,只有 get/set 钩子,那么它就是虚拟的。例如上面 subtotal 没有赋值操作,也没有声明类型默认值。这种属性只能读,不能写。如果你写 $item->subtotal = 100; 会抛 Error,因为只有 get 钩子。
3. 只读属性
可以结合 readonly 吗?在 PHP 8.4 中,readonly 属性是可以和钩子共存的,但有限制:如果属性声明了 readonly,那么不能再有 set 钩子。只有 get 钩子可以,这样保证了初始化后不可变。
public readonly float $maxQuantity {
get => $this->maxQuantity;
}
但这个特性实际中用得少,因为 readonly 主要用于防止外部修改,有 get 钩子再读原始值反而容易绕。我建议项目里还是老老实实用普通属性。
4. 访问控制
钩子可以是 protected 或 private。比如你希望外部可读、但只有内部能设置:
public float $total {
get => $this->price * $this->count;
private set => $this->total = $value; // 这里会有问题吗?不推荐,因为没有后备字段
}
所以这种场景直接用虚拟属性更合适。
性能与适用场景
有人会担心属性钩子每次访问都要执行逻辑,性能会不会比 getter 差?其实 PHP 8.4 在 opcache 层做了优化,钩子的调用开销和普通方法几乎一样。真正影响性能的是你在钩子里面写了大量 I/O,比如数据库查询、调用远程服务。因为钩子在语义上等同于方法,所以规则很简单:不要在 get 钩子里干重活,保持计算简单快速。
适用场景几种:
- DTO、VO 的字段格式化,比如日期格式化、金额单位转换。
- 有依赖关系的计算属性,如订单总额、年龄(由生日算)。
- 对属性赋值做校验、过滤,比如 trim 字符串。
反思:没那么神的“新特性”
说实话,PHP 8.4 的属性钩子更像是语法糖,它不能让你写出别的语言做不到的事。但对程序员体验的改善是巨大的:写少一点,读清楚一点,改起来风险低很多。我最初觉得“不过是省几个方法”,但用下来发现代码里更容易保持“状态一致”了。因为以前如果想通过 getter 方法访问一个计算值,调用方很可能忘了加括号;现在属性钩子强制我们使用纯属性访问,接口看起来自然得多。
另外,它也有坑。比如我一开始就没理清“在 set 钩子里给后备字段赋值会不会触发 set”这个问题。实际规则是:在钩子内部用 $this->prop 直接读写后备值,不会再次进入钩子(前提是当前作用域就是该属性的钩子)。但如果你在 get 钩子里写了 $this->foo = 1,这不是修改后备字段,而是把 foo 当成一个属性来 set,此时如果 foo 没有 set 钩子但可写,它就会调用 set 钩子或直接赋值。这很容易造成逻辑混乱,所以钩子内部访问同名属性时一定要想清楚。
结语
属性钩子绝对是 PHP 8.4 最实用的新语法。如果你正在维护一个拥有大量 Java 风格 getter/setter 的旧项目,不妨抽一个类出来用钩子重写。不用一步到位,可以先从计算属性开始,慢慢把校验逻辑也挪进去。我推荐的顺序是:先写虚拟属性(无后备字段),再给已有属性加 set 校验,最后再考虑访问控制和其他高级玩法。
最后留个小问题:如果订单项里有优惠券,券码要求必须大写字符串,用 set 钩子你会怎么写?欢迎在评论区交流。

