上周维护一个电商老项目,里面有个 Product 类,光 getter 和 setter 就占了 280 多行。价格、库存、折扣、货币单位,每个字段一对方法,方法体清一色 return $this->xxx; 和 $this->xxx = $v;。真正有逻辑的其实只有三个:价格不能为负、库存不能小于 0、折扣率必须在 0 到 1 之间。
PHP 8.4 带来的属性钩子(property hooks)正好是冲这种代码去的。我把这个类重写了一遍,代码从 300 多行缩到 90 行左右,过程中也撞了几次墙。这篇就把完整过程写下来,包括那些文档里一句带过、实际写起来会卡住的地方。
一、属性钩子长什么样
最基础的形式是给属性加一个 get 或 set 块。拿价格举例:
class Price
{
public int $cents {
get => $this->cents;
set {
if ($value < 0) {
throw new InvalidArgumentException('价格不能为负');
}
$this->cents = $value;
}
}
}
几个语法点先讲清楚,否则后面容易看晕:
set里的$value是隐式参数,名字固定,不能改。它就是右边赋进来的那个值。- 钩子内部写
$this->cents,访问的是底层存储值,不会递归调用钩子本身。这一点是 PHP 定的规则,不是巧合。 get => expr;是单表达式简写,等价于get { return expr; }。set的简写形式里,表达式的返回值会成为属性的新值,这比在块里手动赋值更短。- 只写
get不写set,属性对外就是只读的(类比 readonly,但机制不同)。只写set,属性就只能写不能读。
上面这坨如果改用 set 简写会更紧凑:
public int $cents {
get => $this->cents;
set => $value < 0
? throw new InvalidArgumentException('价格不能为负')
: $value;
}
但我个人在业务代码里更倾向用块形式,因为将来加第二条校验的时候不用大改结构。
二、把 Product 类改一遍
先看原来的写法(节选):
class Product
{
private int $priceCents = 0;
private float $discount = 0.0;
private string $currency = 'CNY';
public function getPriceCents(): int { return $this->priceCents; }
public function setPriceCents(int $v): void
{
if ($v < 0) throw new InvalidArgumentException('价格不能为负');
$this->priceCents = $v;
}
public function getDiscount(): float { return $this->discount; }
public function setDiscount(float $v): void
{
if ($v < 0 || $v > 1) throw new InvalidArgumentException('折扣必须在 0~1 之间');
$this->discount = $v;
}
// ... 还有 currency 的三对方法
}
改完之后:
class Product
{
public int $priceCents {
set {
if ($value < 0) {
throw new InvalidArgumentException('价格不能为负');
}
$this->priceCents = $value;
}
}
public float $discount = 0.0 {
set {
if ($value < 0.0 || $value > 1.0) {
throw new InvalidArgumentException('折扣必须在 0~1 之间');
}
$this->discount = $value;
}
}
public string $currency = 'CNY' {
set => strtoupper($value);
}
}
注意 public float $discount = 0.0 { ... } 这个写法:默认值写在属性名后面、钩子块前面。这点和普通属性一致,但位置比想象中要紧 —— 写在钩子块后面是语法错误。
调用侧的代码完全不用动,$p->priceCents = 1999; 和 $p->discount = 0.85; 照样能用,只是现在会走校验。这正是钩子最大的好处:对外还是裸属性访问,对内有了拦截点。以前为了加一个校验,得把全项目的 $p->price 改成 $p->setPrice(),改一次死一次。
三、虚拟属性:算出来的,不是存下来的
商品列表里经常要显示”原价 ¥199,现价 ¥169″。现价是算出来的,不该存。以前用一个 getFinalPrice() 方法,现在可以用虚拟属性:
class Product
{
public int $priceCents {
set {
if ($value < 0) throw new InvalidArgumentException('价格不能为负');
$this->priceCents = $value;
}
}
public float $discount = 0.0;
// 没有底层存储,纯计算
public int $finalCents {
get => (int) round($this->priceCents * (1 - $this->discount));
}
}
这个属性只声明了 get,没有 set 也没有默认值,PHP 会把它当成虚拟属性 —— 不占内存,每次读都重新算。往上面写会直接报错:
$p->finalCents = 100;
// Error: Cannot write to virtual property Product::$finalCents
$p->finalCents = 100;
// Cannot modify readonly property Product::$finalCents
具体报哪条取决于写法,间接修改(比如 $p->finalCents++)报的是第二条,信息不太直观,调试时别被绕进去。
判断一个属性是不是虚拟的,可以直接用反射:
$ref = new ReflectionProperty(Product::class, 'finalCents');
var_dump($ref->isVirtual()); // bool(true)
var_dump($ref->hasHook(PropertyHookType::Get)); // bool(true)
var_dump($ref->hasHook(PropertyHookType::Set)); // bool(false)
序列化的时候要留个心:虚拟属性不会被 json_encode 带出来,因为它在 get_object_vars() 里没有底层值。想让它出现在 JSON 里,得手动展开,或者实现 JsonSerializable。
四、非对称可见性:只读对外,可写对内
老代码里 id 这个字段很典型:创建之后就不该再改,但 ORM 或者工厂方法又需要写。以前要么用 readonly(但那样连内部也改不了第二次),要么写一个 setId() 再加注释”别在外面调用”。
PHP 8.4 的非对称可见性正好解决这个:
class Product
{
public private(set) int $id;
public function __construct(int $id)
{
$this->id = $id; // 类内部可以写
}
}
$p = new Product(42);
echo $p->id; // 42
$p->id = 99;
// Error: Cannot modify private(set) property Product::$id from global scope
语法就是 public private(set) 类型 $属性。set 的可见性可以写成 private 或 protected,get 的可见性则直接写在最前面那个位置。
和 readonly 的区别在于:readonly 是”只能初始化一次”,private(set) 是”外部不能改,内部随便改”。真正常见的需求其实是后者 —— 订单状态、库存数量、余额这些东西,外部不该直接改,内部要能改。
class Wallet
{
public private(set) int $balance = 0;
public function deposit(int $amount): void
{
$this->balance += $amount; // 内部可写
}
public function withdraw(int $amount): void
{
if ($amount > $this->balance) {
throw new RuntimeException('余额不足');
}
$this->balance -= $amount;
}
}
两种写法还能叠在一起用,这就有意思了:
class Product
{
public private(set) string $name {
set => trim($value);
}
}
外面读得到 $p->name,读到的永远是去掉首尾空格之后的值;写的话只有类内部能写。这两套机制是正交的,组合起来没有任何冲突。
五、坑一:数组属性的间接修改会直接报错
这个坑我卡了差不多二十分钟。场景是这样的:
class Cart
{
public array $items = [] {
set => array_values($value);
}
}
$c = new Cart();
$c->items = ['a', 'b'];
$c->items[] = 'c';
// Error: Cannot indirectly modify property Cart::$items with hooks
报错信息其实挺清楚的,只是第一次见到会懵。$c->items[] = 'c' 是一种间接修改:PHP 需要拿到数组的引用再往里追加,但属性有 set 钩子时,底层值不是一个可以直接交给外部引用的东西,引擎干脆就禁止了这种操作。
这跟魔术方法 __get / __set 的行为完全不同 —— 后者遇到间接修改会静默绕过,值改了但校验没跑,是更隐蔽的 bug。钩子的选择是直接报错,虽然一开始会烦,但至少不会埋雷。
解决办法有三个,按场景挑:
方案 A:用 &get 返回引用。属性没有 set 钩子时,get 可以声明成返回引用:
class Cart
{
private array $items = [];
public array $list {
&get {
return $this->items;
}
}
}
$c = new Cart();
$c->list[] = 'a'; // 可以,因为是引用
代价是校验彻底没了,引用一旦交出去,谁改、改多少次你都不知道。适合纯粹为了读写方便的场合。
方案 B:虚拟属性 + 显式方法。写操作全部走方法,读操作走虚拟属性:
class Cart
{
private array $items = [];
public array $items {
get => $this->items;
}
public function add(string $sku): void
{
if (in_array($sku, $this->items, true)) {
throw new DomainException('商品已存在');
}
$this->items[] = $sku;
}
}
这个方案最干净,但也最啰嗦,适合确实需要业务规则的集合。
方案 C:换成对象集合。用 DoctrineCommonCollectionsCollection 或者自己写一个 ItemCollection,把 add() 收敛到集合类里,属性本身只赋值不修改。大项目里这个方案最稳,因为集合内部能缓存、能排序、能延迟加载。
六、坑二:和 readonly 不能共存
class Foo
{
public readonly int $x {
get => $this->x * 2;
}
}
// Fatal error: Cannot declare hooked property Foo::$x as readonly
这条很直接:有钩子的属性不能是 readonly。之前想当然地觉得”只读 + get 钩子”应该很配,结果直接编译不过。
要只读效果,用 public private(set),或者干脆只声明 get 钩子。后者连类内部也写不进去,更严格。
七、坑三:钩子里别写递归
下面这段看起来没问题,实际会挂:
class Foo
{
public string $name {
get => strtoupper($this->getName());
}
public function getName(): string
{
return $this->name; // 又触发 get 钩子
}
}
$f = new Foo();
$f->name = 'a';
echo $f->name; // 栈溢出
关键在于:钩子内部访问 $this->name 是安全的(走底层值),但通过方法间接访问就又不安全了。$this->getName() → return $this->name → 触发 get 钩子 → 再调 getName(),无限套娃。
规则很简单:钩子里只碰 $this->同名属性 和其他不相干的属性,不要调用任何可能反过来访问本属性的方法。这条规则写起来容易,改别人代码时最容易忽略。
八、惰性加载:钩子最实用的场景
说完坑说个真香的用法。报表对象里的 rows 往往要查数据库,但绝大多数请求根本不会读它。以前要么在构造函数里查(浪费),要么写 getRows() 加个 if ($this->rows === null) 判断(丑)。
class Report
{
public array $rows {
get {
if (!isset($this->rows)) {
$this->rows = $this->loadRows();
}
return $this->rows;
}
}
private function loadRows(): array
{
return [/* 这里查数据库 */];
}
}
isset($this->rows) 对未初始化的 typed property 返回 false,第一次读的时候才真的去查。第二次读直接命中缓存。整个过程对外就是一个普通的 $report->rows,调用方完全感知不到延迟加载的存在。
注意 $rows 没有赋默认值 —— 如果写了 public array $rows = [];,那 isset 永远为真,惰性加载就失效了。这是个必须记住的细节。
同样的套路可以套在任意”创建成本高、但未必用到”的对象上,比如 public PdfRenderer $pdf { get { ... } }。
九、接口里的属性
PHP 8.4 允许在接口里声明抽象属性,这让”必须有某个字段”这件事终于能写进类型系统了:
interface HasPrice
{
public int $priceCents { get; }
}
class Book implements HasPrice
{
public int $priceCents {
get => $this->basePrice * 100;
}
public function __construct(private float $basePrice) {}
}
注意实现类这里写的是虚拟属性,值是从 basePrice 算出来的。接口只关心”能读到 int“,不关心你是不是真的存了一个字段。
如果实现类只写一个普通属性 public int $priceCents = 0; 会不会通过?答案是不会,接口要求的是带有 get 钩子的属性,普通属性不满足这个契约。这条规则挑明了其实合理:只有钩子才能保证子类里这个属性确实是按契约返回的。
十、反射、IDE 与静态分析
属性钩子上线后,工具链的支持是个现实问题。
| 工具 | 支持状态 | 备注 |
|---|---|---|
| ReflectionProperty | 完整 | getHooks()、hasHook()、isVirtual() |
| PHPStan 2.x | 支持 | 能识别钩子返回类型,但 virtual property 的类型推导偶尔会退化成 mixed |
| Psalm | 部分 | 较新版本才跟上的语法解析 |
| PhpStorm 2024.3+ | 支持 | 较早版本会把钩子块标红 |
| xdebug | 可用 | 断点会停在钩子内部,堆栈里能看到虚拟属性调用 |
如果项目里用了 Doctrine 之类的 ORM,要特别小心:ORM 反射读属性时,有些版本会读到虚拟属性并尝试赋值,然后被你自己的只读声明怼回去。稳妥的做法是给虚拟属性加 #[ORMIgnore] 或者干脆不在实体上用虚拟属性,用普通方法。
自己写反射代码时,跳过虚拟属性的写法:
$ref = new ReflectionClass(Product::class);
foreach ($ref->getProperties() as $prop) {
if ($prop->isVirtual()) {
continue;
}
// 只处理真正存数据的属性
}
十一、还不能升级怎么办
8.4 目前还没到所有生产环境都敢上的阶段,很多项目还在 8.2、8.3。这时候别干等,先把代码结构往钩子靠:
- 把所有直接操作属性的地方改成走方法,命名统一成
getXxx()/setXxx()。将来升级时这些方法可以整体删掉,外部调用点改成属性访问就是。 - 私有属性加
@readonly文档注释,配合 PHPStan 的readOnlyProperties规则,先拿到一部分静态保障。 - 校验逻辑抽成独立的 Value Object,比如
Money、Percentage。这些类本身就不需要钩子,升级的时候直接受益。
等升级那天,改动会集中在类定义本身,业务层几乎不用碰。这比临时加一堆 __get / __set 魔术方法要主动得多 —— 魔术方法那套性能差,静态分析还识别不了,属于技术债里最难还的那一类。
十二、一点判断
属性钩子不是银弹,它解决的是”我想拦截属性访问,但不想破坏 $obj->prop 这种写法”这一类问题。如果你的属性本来就有复杂的业务逻辑、需要多次校验、需要事务边界,那还是老老实实写方法,钩子的定位是替代那些纯粹的 getter / setter 样板,不是替代领域方法。
但就冲”300 行缩到 90 行,对外接口一行不改”这一点,8.4 这个特性已经值回票价了。剩下的就是等主流框架和 ORM 把坑填平,那大概还需要一阵子。

