我从 PHP 5.3 时代就开始写业务代码,这些年最让我有点厌烦的东西,大概就是实体类里那一片片只读不改的 getter。以前为了把某个金额字段保护起来,要么加一个方法,要么用反射绕来绕去,代码越写越绕。
直到 PHP 8.4 正式把属性钩子(Property Hooks)带进来,我才觉得“属性”这两个字终于名副其实了。这篇文章我不想只列文档语法,而是直接用一个带折扣和库存预警的商品类,带你把完整的实战思路过一遍。
从 Getter 到属性钩子,差的不只是几个字母
先看一个很常见的类:商品有一个原始价格,外面读钱的时候要打八折。老写法里,你需要把“价格”藏成私有属性,然后暴露一个 getPrice() 方法。
class Product
{
private float $originalPrice = 0;
public function setPrice(float $price): void
{
if ($price <= 0) {
throw new InvalidArgumentException('价格必须大于 0');
}
$this->originalPrice = $price;
}
public function getPrice(): float
{
return round($this->originalPrice * 0.8, 2);
}
}
业务调用起来很别扭。负责渲染的同事总得问:到底哪些地方该用 getPrice(),哪些地方能直接读属性?这种心智负担叠加多了,就容易在代码里看到各种奇奇怪怪的自创方法名。
属性钩子的思路很简单:把读和写的拦截逻辑,直接挂在属性声明上。
$product->price = 100;
echo $product->price;
外部读写的姿势和普通属性一样,但计算和校验逻辑也没丢。这就是我想要的。
实战:一个商品类的价格与库存逻辑
下面我用一个带折扣的商品类做演示。需求是:
- 外部设置价格时做正数校验,读取时自动返回折后价;
- 折扣百分比外部只能读,不能随便改;
- 库存扣减低于安全线时自动给出一条预警。
<?php
class Product
{
public string $name;
private float $originalPrice = 0;
public private(set) float $discountPercent = 0;
public float $price {
get {
return round($this->originalPrice * (1 - $this->discountPercent / 100), 2);
}
set(float $value) {
if ($value <= 0) {
throw new InvalidArgumentException('价格必须大于 0');
}
$this->originalPrice = $value;
}
}
private int $currentStock = 0;
private int $lowStockThreshold = 10;
public int $stock {
get => $this->currentStock;
set(int $value) {
if ($value < 0) {
throw new InvalidArgumentException('库存不能为负数');
}
$this->currentStock = $value;
if ($value < $this->lowStockThreshold) {
printf("[库存预警] %s 当前库存只剩余 %d 件n", $this->name, $value);
}
}
}
public function __construct(string $name, float $price, int $stock = 0)
{
$this->name = $name;
$this->price = $price;
$this->stock = $stock;
}
public function setDiscount(float $percent): void
{
if ($percent < 0 || $percent > 90) {
throw new InvalidArgumentException('折扣必须在 0 到 90 之间');
}
$this->discountPercent = $percent;
}
}
其中这段是最关键的部分:
public float $price {
get {
return round($this->originalPrice * (1 - $this->discountPercent / 100), 2);
}
set(float $value) {
if ($value <= 0) {
throw new InvalidArgumentException('价格必须大于 0');
}
$this->originalPrice = $value;
}
}
price 实际上是一个虚拟属性,它自己没有额外的存储空间,真实原价放在 originalPrice 里。读取价格时,把原价拿出来按当前折扣算一遍;设置价格时,只做校验和存储。这么写以后,任何外部代码都不需要关心“折扣后价格”是怎么算出来的。
下面是使用效果:
$product = new Product('机械键盘', 399, 50);
$product->setDiscount(20);
echo $product->price, PHP_EOL; // 319.2
echo $product->discountPercent, PHP_EOL; // 20
echo $product->stock, PHP_EOL; // 50
try {
$product->discountPercent = 30;
} catch (Error $e) {
echo '不能直接改折扣: ', $e->getMessage(), PHP_EOL;
}
$product->stock = 5; // 库存低于 10,触发预警
一个容易绕晕的细节
property hooks 刚发布时,社区里不少人第一反应是:能不能在 set 钩子里直接写 $this->sameProperty = $value?其实这里要看你声明的属性是一个有后备存储的属性,还是一个纯虚拟属性。
如果属性本身没有后备存储,你在钩子里再对同一个名字赋值,等待你的只会是运行时错误或者意想不到的递归。即便语法上允许某些情况下的自赋值,这种隐式行为也很难读。
所以我更推荐上面的做法:把真实状态放到一个独立的私有属性里,让有钩子的属性保持“虚拟”状态。这样既不会踩递归坑,代码逻辑也更直白。等以后大家都习惯了后备字段的细节,再考虑更紧凑的写法也不迟。
不对称可见性:订单优惠码更省心
PHP 8.4 的另一个特性“不对称可见性”和属性钩子特别搭,它就是用来解决“外部能读但不能写”这个老问题的。
public private(set) float $discountPercent = 0;
意思是这个属性任何人都可以读,但是只有类内部才能写。外部想改折扣,只能调 setDiscount() 方法。
再举一个订单场景。每个订单只能绑定一次优惠码,绑定之后如果需要释放,也只能走内部方法。
class Order
{
public string $orderNo;
public private(set) ?string $couponCode = null;
public function __construct(string $orderNo)
{
$this->orderNo = $orderNo;
}
public function applyCoupon(string $code): void
{
if ($this->couponCode !== null) {
throw new LogicException('这个订单已经绑定了优惠码');
}
// 这里可以接上真实的优惠券核销逻辑
$this->couponCode = $code;
}
public function releaseCoupon(): void
{
$this->couponCode = null;
}
}
$order = new Order('D20250715001');
echo $order->couponCode ?? '暂无优惠码', PHP_EOL;
$order->applyCoupon('SAVE50');
echo $order->couponCode, PHP_EOL;
$order->releaseCoupon();
echo $order->couponCode ?? '优惠码已释放', PHP_EOL;
以前要做到同样的效果,我得写一个私有属性再加一个公开 getter。现在一行 public private(set) 就解决了。
什么场景不建议用属性钩子
属性钩子不是万能钥匙。如果一个 get 钩子里要查数据库、调外部接口、做复杂缓存,那我建议你还是老老实实写方法。
钩子的调用频率通常比方法高,而且其他地方读属性时不会意识到背后有开销。一旦把重逻辑塞进去,排查性能问题的时候会有点崩溃。
另外,老项目如果重度依赖 PHPStan、Psalm 或者其他静态分析工具,要先确认版本是否支持 8.4 的钩子语法。目前不少工具能解析了,但旧版仍可能存在误报。
小结
PHP 8.4 的属性钩子和不对称可见性,说到底是把原来散落在方法里的样板逻辑收拢回属性本身。它没有改变 PHP 的本质,只是让代码更贴近人的直觉。
如果你最近正好在做一个 DTO、一个实体类,又或者被一堆 getter 搞得很烦躁,可以试着把其中几个改造成属性钩子,感受一下这种“读属性就像读字段”的体验。对我来说,这是这几年 PHP 版本迭代里最让人觉得痛快的一个变化。

