PHP 8.4 的新东西不少,但真正会改变日常写法的只有两个:property hooks 和不对称可见性(asymmetric visibility)。前者干掉 getter/setter,后者让你写出”外面只读、里面能改”的属性。单独用都还行,合起来用才是重点。
下面拿一个订单明细行的价格模型走一遍,从老写法改到属性钩子,代码全部可跑,环境要求 PHP 8.4 以上。
一、先看看老写法长什么样
一个订单行,需要保证 SKU 格式合法、数量在 1 到 999 之间、单价非负、折扣不超过 80%,另外还要能算出小计和折后金额。用传统 getter/setter 写出来大概是这样:
<?php
declare(strict_types=1);
final class OrderLine
{
private string $sku;
private int $quantity;
private int $unitPriceCents;
private int $discountBp;
public function __construct(
string $sku,
int $quantity,
int $unitPriceCents,
int $discountBp = 0
) {
$this->setSku($sku);
$this->setQuantity($quantity);
$this->setUnitPriceCents($unitPriceCents);
$this->setDiscountBp($discountBp);
}
public function getSku(): string
{
return $this->sku;
}
public function setSku(string $sku): void
{
$normalized = strtoupper(trim($sku));
if (!preg_match('/^[A-Z0-9-]{3,32}$/', $normalized)) {
throw new InvalidArgumentException("非法 SKU:{$sku}");
}
$this->sku = $normalized;
}
public function getQuantity(): int
{
return $this->quantity;
}
public function setQuantity(int $quantity): void
{
if ($quantity < 1 || $quantity > 999) {
throw new InvalidArgumentException('数量必须在 1~999 之间');
}
$this->quantity = $quantity;
}
public function getUnitPriceCents(): int
{
return $this->unitPriceCents;
}
public function setUnitPriceCents(int $cents): void
{
if ($cents < 0) {
throw new InvalidArgumentException('单价不能为负');
}
$this->unitPriceCents = $cents;
}
public function getDiscountBp(): int
{
return $this->discountBp;
}
public function setDiscountBp(int $bp): void
{
if ($bp < 0 || $bp > 8000) {
throw new InvalidArgumentException('折扣基点必须落在 0~8000');
}
$this->discountBp = $bp;
}
public function getSubtotalCents(): int
{
return $this->unitPriceCents * $this->quantity;
}
public function getPayableCents(): int
{
return (int) round($this->getSubtotalCents() * (10000 - $this->discountBp) / 10000);
}
}
九十行,八成是模板代码。真正干活的是四个校验分支和两行算术。问题是这套样板每加一个字段就复制一遍,改字段名得同时动三处,忘了改哪处 IDE 还不一定报错。
二、属性钩子的核心机制
属性钩子允许你直接在属性声明上挂 get 和 set 两个块,赋值和取值时自动执行。分两种形态:
带存储的属性。只写一个钩子,PHP 照样给属性分配内存。钩子内部对 $this->prop 的读写,操作的就是那块底层存储,不会再回调钩子,所以不存在递归问题。
虚拟属性。get 和 set 都写了,且没有默认值,PHP 不分配存储。值从别的地方算出来,或者写进别的字段。
另外还有不对称可见性,语法是 public private(set):读权限公开,写权限锁在类内部。外部给属性赋值直接报 Error,类内部赋值正常走 set 钩子。
这三样凑一块,正好能表达”从外面看是个属性,从里面看是带校验的字段”。
三、改写后的 OrderLine
<?php
declare(strict_types=1);
final class OrderLine
{
private const MAX_QUANTITY = 999;
private const MAX_DISCOUNT_BP = 8000;
public private(set) string $sku {
set (string $value) {
$normalized = strtoupper(trim($value));
if (!preg_match('/^[A-Z0-9-]{3,32}$/', $normalized)) {
throw new InvalidArgumentException("非法 SKU:{$value}");
}
$this->sku = $normalized;
}
}
public private(set) int $quantity {
set (int $value) {
if ($value < 1 || $value > self::MAX_QUANTITY) {
throw new InvalidArgumentException(
sprintf('数量必须落在 1~%d,收到 %d', self::MAX_QUANTITY, $value)
);
}
$this->quantity = $value;
}
}
public private(set) int $unitPriceCents {
set (int $value) {
if ($value < 0) {
throw new InvalidArgumentException('单价不能为负');
}
$this->unitPriceCents = $value;
}
}
public private(set) int $discountBp {
set (int $value) {
if ($value < 0 || $value > self::MAX_DISCOUNT_BP) {
throw new InvalidArgumentException(
sprintf('折扣基点必须落在 0~%d,收到 %d', self::MAX_DISCOUNT_BP, $value)
);
}
$this->discountBp = $value;
}
}
public int $subtotalCents {
get => $this->unitPriceCents * $this->quantity;
}
public int $payableCents {
get => (int) round($this->subtotalCents * (10000 - $this->discountBp) / 10000);
}
public function __construct(
string $sku,
int $quantity,
int $unitPriceCents,
int $discountBp = 0
) {
$this->sku = $sku;
$this->quantity = $quantity;
$this->unitPriceCents = $unitPriceCents;
$this->discountBp = $discountBp;
}
public function changeQuantity(int $quantity): void
{
$this->quantity = $quantity;
}
}
不到六十行。几个值得说的点:
构造函数里赋值会触发 set 钩子,所以校验只需要写一次,不用像老版本那样在构造函数和 setter 里各来一遍。
subtotalCents 和 payableCents 是虚拟属性,只有 get。调用方写 $line->subtotalCents,不用带括号,读起来跟真属性一样,但每次访问都实时计算。金额不会因为中间改了数量而变旧。
金额统一用”分”存 int,折扣用基点(万分比)存 int,整条链路不碰浮点。这是电商线上一贯的做法,跟属性钩子没关系,但顺手强调一下。
四、跑一遍看看
<?php
$line = new OrderLine(' sku-001 ', 3, 1990, 1500);
var_dump($line->sku); // string(7) "SKU-001"
var_dump($line->quantity); // int(3)
var_dump($line->subtotalCents); // int(5970)
var_dump($line->payableCents); // int(5075)
$line->changeQuantity(5);
var_dump($line->subtotalCents); // int(9950)
var_dump($line->payableCents); // int(8458)
试着从外面直接写:
$line->quantity = 5;
会得到 Error: Cannot modify private(set) property OrderLine::$quantity。这是不对称可见性在拦,根本到不了 set 钩子。
真正需要改数量,走 changeQuantity(),方法体在类内部,赋值合法,set 钩子照常执行:
$line->changeQuantity(0);
抛 InvalidArgumentException: 数量必须落在 1~999,收到 0。
校验入口收敛成了一个。以前可能有人在 controller 里改数量、有人在 service 里改数量、有人直接操作属性,现在只剩一条路。
五、几个会踩的坑
1. 构造器属性提升用不了钩子
下面这段是语法错误,PHP 直接拒绝编译:
public function __construct(
public private(set) string $sku { set => trim($value); }
) {}
提升语法和钩子互斥。想省事的话,保持普通属性声明 + 手写构造函数赋值。多打几行,能接受。
2. readonly 和 set 钩子不能共存
public readonly int $quantity {
set (int $value) { /* ... */ }
}
readonly 的语义是”初始化之后再也不变”,set 钩子的语义是”每次写入都过一遍逻辑”,两者根本冲突。PHP 会直接报错。需要”一次写入 + 校验”的场景,用不对称可见性:public private(set),校验在 set 钩子里做完,外部就再也写不进去了,效果其实一样。
3. 虚拟属性没有”引用语义”
给虚拟属性取引用会失败:
$ref = &$line->subtotalCents; // Error
如果虚拟属性返回的是数组,$line->tags[] = 'x' 这种间接修改会先 get 拿一份副本、改完再尝试 set。只定义了 get 的虚拟属性在这一步会直接报错。要改就老老实实调方法。
4. clone 不走钩子
克隆一个对象时,挨个复制的是底层存储值,set 钩子不会重新执行一遍。所以别把钩子当”对象生命周期钩子”用,它只负责属性读写这一件事。克隆后的重新校验需要自己写在 __clone() 里。
5. 序列化要看清楚
json_encode() 一个带虚拟属性的对象,输出里会包含虚拟属性的值。如果下游接口不需要 payableCents 这种东西,实现 JsonSerializable 手动挑字段,别指望它自动跳过。
六、什么时候别用属性钩子
钩子好用,但不是所有属性都值得挂一个。判断标准很简单:这个属性上有没有不变式(invariant)需要一直维持?
有,就挂。比如金额非负、百分比在 0 到 100、状态只能单向流转、字符串必须匹配某种格式。这些约束一旦成立,就该在写入那一刻被强制检查,而不是指望调用方自觉。
没有,就别挂。纯 DTO、跟数据库表一一对应的 Entity、内部使用的临时结构体,直接 public 字段完事。给一个永远不变的 $id 写 set 钩子是纯粹的噪音。
另外,性能敏感的热路径要留个心眼。属性钩子在编译期就会展开成方法调用,比 __get / __set 那套动态查找便宜得多,但毕竟多了一次函数调用。每秒几百万次读写、且属性逻辑为空的地方,老老实实用公开字段。
七、收个尾
把校验逻辑从 setter 里搬进属性声明,表面上是省了几十行代码,实际收益在别处:
一是约束和字段绑在了一起。以后有人加字段,一眼就能看到旁边的钩子里该补什么。老写法里,校验散在 setter 中,新来的同事很可能只写了 $this->foo = $foo; 就提交了。
二是类型系统终于把”只读”这件事说清楚了。public private(set) 是从语法层面表达意图,比在注释里写”请勿直接赋值”靠谱得多。
三是虚拟属性让派生值不用再伪装成方法。调用方写 $line->payableCents,读起来就是”订单行的折后金额”,而不是”调用计算折后金额的方法”。语义上顺一点。
如果手上的项目还在 PHP 8.1 或 8.2,升级到 8.4 之前先评估一下依赖链。钩子本身没有运行时兼容负担,但一旦用了,代码就回不去了。

