去年底接到一个老电商项目的维护活,代码规模不大,但订单实体那块看着就头疼。`app/Entity/Order.php` 六百二十行,三十多个字段,每个字段一套 getter/setter,剩下的就是各种 `getXxxText()`、`setXxxByYyy()` 之类的辅助方法。
同事交接的时候跟我说:”这文件别动,动了容易出事。”我当时没太在意,直到一周后真的出了事。
那笔从 1 元变 100 元的订单
仓库里 `order_amount` 字段存的是「分」,整数。实体类里有个 `getAmount()` 会除以 100 转成元返回,`setAmount()` 反过来乘以 100。这个设计本身没毛病,问题在于总有地方绕过去。
那次是新写的对账脚本,直接 `$order->amount = 1.35;`,注意是直接访问属性——实体类里根本没有声明这个属性,PHP 的动态属性就这么默默接住了,1.35 原封不动进了内存。脚本执行完后走了一个批量更新,1.35 被原样写进了 `order_amount` 字段。第二天早上财务对账,这笔订单显示金额 1.35 元,但它实付了 135 元(正确的分单位应该是 135)。再一查,类似的问题订单有四十多笔。
这事追根到底不是谁的疏忽,是「同一个数据,有两套访问路径」这个结构本身就在邀请 bug 发生。只要 `$order->amount` 能绕过换算逻辑,早晚有人会踩。
所以这次动手,第一优先级就是把这个口子彻底堵上。正好那段时间在跟进 PHP 8.4 的迁移,属性钩子(Property Hooks)算是 8.4 里最实用的一个特性,就用它了。
属性钩子到底长什么样
给不大熟悉这个特性的同学扫个盲。简单说,属性钩子允许你把 get/set 逻辑直接写在属性声明里,PHP 在执行读写时自动触发:
<?php
class Temperature
{
public float $celsius = 0.0;
public float $fahrenheit {
get => $this->celsius * 9 / 5 + 32;
set (float $value) {
$this->celsius = ($value - 32) * 5 / 9;
}
}
}
$t = new Temperature();
$t->fahrenheit = 212;
echo $t->celsius; // 100
echo $t->fahrenheit; // 212
这里 `$fahrenheit` 就是一个**虚拟属性**——它没有自己的存储空间,读写都直接落到 `$celsius` 上。判断标准很简单:钩子内部有没有引用 `$this->属性名`。没有就是虚拟的,有就是带底层存储的。
另外 8.4 一起进来的还有不对称可见性(Asymmetric Visibility):
class Foo
{
public private(set) string $bar = 'baz';
}
$f = new Foo();
echo $f->bar; // OK
$f->bar = 'x'; // Fatal error: Cannot modify private(set) property
`public private(set)` 读可见性是 public,写只在类内部可用。以前这种需求要么写 `private $bar` 加个 `getBar()`,要么干脆放任外部随便改。现在一行搞定。
两个特性配合起来,正好能对症下药。下面这堆代码是重构之后的实际产物,为了便于阅读我删掉了几个不相关的字段。
第一步:把金额锁死
先处理事故源头。目标很明确:外部能读、能写,但不管怎么写,底层存的永远是「分」这个整数,容不得半点闪失。
<?php
declare(strict_types=1);
namespace AppEntity;
use InvalidArgumentException;
class Order
{
// 真正的存储字段,类外部无法直接接触
protected int $amountCents = 0;
public float $amount {
get => $this->amountCents / 100;
set (float|int $value) {
if ($value < 0) {
throw new InvalidArgumentException('订单金额不能为负数');
}
if ($value > 99999999.99) {
throw new InvalidArgumentException('订单金额超出上限');
}
$this->amountCents = (int) round($value * 100);
}
}
}
注意 `amount` 是虚拟属性,`get` 和 `set` 都没碰 `$this->amount`,所以它没有自己的底层存储。外部 `$order->amount = 1.35`,set 钩子被触发,`round(1.35 * 100)` 得到 135。想绕过?绕不了——属性访问就这么一条路。
有个细节值得说:`round()` 不能省。浮点乘法会有精度问题,`19.99 * 100` 在某些平台上会得到 `1998.9999999999998`,直接 `(int)` 转就是 1998,少一分。这种一分钱的差额在会计系统里是要出人命的,我们之前老代码就有这个 bug,只是没人发现。
当然,如果你能改表结构,我强烈建议把对外接口也改成用整数分表示,浮点数在金额场景下就不该出现。但这是遗留项目,那么多个调用方,改接口的成本比用钩子兜住高得多。
第二步:状态字段和它的文案
以前状态字段是这样的:
// 老代码,几十处这种写法
$statusText = match ($order->order_status) {
0 => '待付款',
1 => '已付款',
2 => '已发货',
3 => '已完成',
4 => '已退款',
default => '未知',
};
改起来就顺手多了。先上个枚举:
enum OrderStatus: int
{
case Unpaid = 0;
case Paid = 1;
case Shipped = 2;
case Completed = 3;
case Refunded = 4;
public function label(): string
{
return match ($this) {
self::Unpaid => '待付款',
self::Paid => '已付款',
self::Shipped => '已发货',
self::Completed => '已完成',
self::Refunded => '已退款',
};
}
}
实体里:
class Order
{
public OrderStatus $status = OrderStatus::Unpaid;
public string $statusText {
get => $this->status->label();
}
}
`$statusText` 是只读虚拟属性,想读 `$order->statusText` 随时随地都能拿到「已发货」这种中文,不用再去调什么 `getStatusText()`。而且因为它只有 get 钩子没有 set 钩子,外部想给 `$order->statusText = ‘xxx’` 写东西会被直接拒绝,编不过去。
这种「只有 get 钩子的虚拟属性」几乎是重构旧 getter 方法最标准的路径。
第三步:订单号的写入控制
订单号生成后绝不允许被改,以前的写法是 `private $orderNo;` 加 `getOrderNo()`。改完之后:
class Order
{
public private(set) string $orderNo;
public function __construct(string $orderNo)
{
$this->orderNo = $orderNo;
}
}
外部可以 `$order->orderNo` 读,但写的话直接抛错。类内部(构造函数里)照常赋值。
这里有个注意事项——**`private(set)` 和 set 钩子不能共存**。你想给写入加逻辑,就得用 set 钩子;想限制可见性,就用 `private(set)`。二选一,不能既 `public private(set)` 又定义一个 `set` 块。我们有个字段一开始想「既限制外部写、又在内部写入时做格式化」,试了半天才发现只能改用 set 钩子 + 内部私有方法的形式。
第四步:金额的展示格式
class Order
{
protected int $amountCents = 0;
public float $amount {
// ...
}
public string $amountDisplay {
get => number_format($this->amountCents / 100, 2, '.', '');
}
}
以前是 `getAmountDisplay()`,现在是属性访问,模板里直接 `{$order.amountDisplay}` 就行。PHP 模板引擎对这种访问方式支持都比方法调用好。
到这里核心部分就改完了,`Order.php` 从 620 行降到了 240 行左右——主要省下来的是三十多组 getter/setter 的方法签名和花括号。
不过事还没完。真正的麻烦在后面。
坑 1:虚拟属性在 `get_object_vars()` 里消失了
他们项目里有一套自己的序列化逻辑,大致是这样的:
public function toArray(AppEntityOrder $order): array
{
return get_object_vars($order);
}
第一版改完之后,接口返回里 `amount`、`statusText`、`amountDisplay` 全都不见了,前端立刻报错。原因很简单:`get_object_vars()` 只返回**有底层存储的下标存在**的属性,虚拟属性不在其中,因为对象内部压根没有那个槽位。
解法有两种,我选了后者:
// 方案一:把需要序列化的字段改成带底层存储的属性(但如果是为了兼容 JSON 就没必要)
// 方案二:实现 JsonSerializable
class Order implements JsonSerializable
{
public function jsonSerialize(): array
{
return [
'order_no' => $this->orderNo,
'status' => $this->status->value,
'status_text' => $this->statusText,
'amount' => $this->amount,
'amount_display' => $this->amountDisplay,
];
}
}
其实这反而更好,白名单列出了哪些字段可以对外,不会因为内部新增一个字段就莫名其妙地被曝出去。
坑 2:`&$order->amount` 不能用了
重构后第一次 CI 挂了一片,报错是 `Cannot take a reference to a property with hooks`。看了下是排序逻辑里的:
usort($orders, function (&$a, &$b) {
return $a->amount <=> $b->amount; // 这里没问题
});
// 真正出问题的是用一个老工具函数:
function sortByAmount(array &$list) {
usort($list, fn($a, $b) => $a->amount <=> $b->amount);
}
// 但某处代码写成了:
$tmp = &$order->amount; // 直接对带钩子的属性取引用,致命错误
PHP 官方立场很明确:带钩子的属性不能取引用,因为钩子本质上是个函数,没有稳定的内存地址。需要处理值的地方,先赋值到局部变量,或者直接改成方法调用。
这个限制不太好绕过。如果某个字段确实需要频繁引用传递,要么放弃对它用钩子,要么把整个访问改造成方法调用,接受一个 `getAmount()`。
坑 3:带钩子的属性不能 `readonly`
我们原本有好几个字段想做成不可变的:
// 这样写会报错
public readonly string $orderNo {
get => $this->internalNo;
}
PHP 8.4 明确规定:定义过钩子的属性不能同时是 `readonly`。因为 `readonly` 的语义是「初始化后不可写」,而钩子允许你在 set 里做各种事情,两者语义冲突。
替代方案:用 `private(set)` 加上构造函数赋值,或者干脆创建一个专用的值对象。说实话,实现不可变数据最好的方式还是从结构上解决——把 Order 的字段迁到 readonly 的值对象里,Order 本身只持有这些对象的引用。这个改动大,我暂时没动,但后面肯定会做。
坑 4:接口里能声明的属性,只能是虚拟的
我们之前有个 `HasDisplayName` 接口:
interface HasDisplayName
{
public function getDisplayName(): string;
}
想改成属性声明,直觉上想这么写:
// 错的
interface HasDisplayName
{
public string $displayName = '';
}
会被拒绝。接口里的属性声明不能有默认值,也不能有底层存储,只能声明钩子:
interface HasDisplayName
{
public string $displayName { get; }
}
翻译成人话就是:”任何实现这个接口的类,都必须有一个可以从外部读到的 $displayName 属性。”实现类可以是虚拟属性(get 钩子返回计算值),也可以是普通有底层存储的属性,都行,只要外部能读、返回 string 就可以。这条规则设计得挺好——接口本来就不该约束存储方式。
坑 5:反射写入会触发钩子,不是所有 ORM 都准备好了
他们用的是自研 ORM,hydrate 的时候用反射逐字段赋值:
$rp = new ReflectionProperty(Order::class, 'amount');
$rp->setValue($order, $row['amount']);
PHP 8.4 起,`ReflectionProperty::setValue()` 会触发属性钩子。也就是说,从数据库读出来的是 135(分),但 setValue 把 135 当成了 135 元传进去,钩子乘以 100 变成了 13500 分。
这个坑非常隐蔽,因为只在金额大于 0 的订单上出错,测试环境数据稀疏,跑一遍没发现,上线第二天就被财务群 @ 了。
8.4 专门为此加了 `setRawValue()` 和 `getRawValue()`,绕过钩子直接读写底层:
$rp = new ReflectionProperty(Order::class, 'amountCents');
$rp->setRawValue($order, (int) $row['amount']);
注意这时候用的是内部字段 `amountCents`,不是虚拟的 `amount`。虚拟属性没有底层存储,`setRawValue()` 用不上。所以 hydrate 逻辑必须明确知道:哪个字段是真实的存储列,哪个是虚拟属性。
如果你们用的是 Doctrine 或者 Eloquent,我建议先别急着上钩子——查一下当前 ORM 版本对 8.4 的支持情况。至少在我动手的时候,Doctrine 2.x 那套基于反射的 hydrate 是有问题的,3.x 改了做法。Eloquent 因为内部实现大量用 `__get` / `__set` 魔术方法,跟钩子的兼容性也要单独测。
坑 6:`isset()`、`empty()` 和 `unset()` 的行为会变
对于虚拟属性:
$order->statusText = 'x'; // 报错,因为只有 get 钩子
isset($order->statusText); // 等价于判断 $order->statusText !== null,会调 get 钩子
empty($order->statusText); // 同上
unset($order->statusText); // 报错,没有定义 unset 钩子
最后那条坑了我半小时。有段老代码做内存清理时调了 `unset($order->xxx)`,这个操作以前对一个普通属性是合法的,但改成虚拟属性后直接抛致命错误。得改成不 unset,或者把字段重新定义成带底层存储的属性。
另外 `isset()` 对虚拟属性其实意义不大——因为你调的是 get 钩子,返回值非 null 就 true。别指望它表达任何「有没有被赋过值」的语义。
迁移到 8.4 的实操节奏
上面这些坑如果一股脑撞上去,很容易把整个团队劝退。我们实际是这样推进的:
第一阶段(一周):只升级 PHP 到 8.4,不改任何业务代码。跑测试、跑压测、盯着所有报错日志。这一步主要是先确认 8.4 本身对现有代码的兼容性,别两件事混在一起做。我们踩到了 `E_STRICT` 之类的边缘问题,但都不算严重。
第二阶段(两周):引入 PHPStan(level 6),让它扫描整个项目。属性钩子的类型推导 phpstan 那边支持得比较完整,能提前找出「接口属性没实现」「虚拟属性和 getter 方法冲突」这类静态问题。这步做完,实际改代码时心里有底了。
第三阶段(持续):新代码统一用属性钩子写,老代码的 getter/setter 保留不动。两者共存没问题——`$order->getAmount()` 和 `$order->amount` 可以同时存在,钩子不会影响同名的普通方法。等某个类因为其他原因需要改动时,顺手把它们改造掉。
这样做的好处是节奏可控。一次性全改必然出事,光那六百二十行的 Order 就够喝一壶了,何况项目里还有几十个类似的实体类。
到底快了还是慢了
性能这块我专门在压测环境做了一组对比:同一份订单列表,1000 条,分别用旧版(getter 方法)和新版(属性钩子)遍历、取金额、取状态文案。
结果差异在噪声范围内,几轮下来新旧两边有的轮次快有的轮次慢,最大偏差 3% 左右。属性钩子和普通方法调用在引擎层面其实很接近,钩子不需要走完整的参数栈和方法分派流程,理论上还略快一点,但被 JIT 一优化就基本抵消了。
真正拉开差距的是「有没有把虚拟属性放进热循环」。虚拟属性的每一次读都要执行一遍钩子函数,如果在百万级循环里读,累积开销会明显。遇到这种场景,在循环外取值缓存到局部变量就行,这个优化跟以前把 getter 提到循环外是一个道理。
回头看
重构上线后,那笔「1 元变 100 元」的事故从原理上就不可能再发生了——因为外部已经没有绕过换算逻辑的路径。写脚本的时候误用 `$order->amount = 135`(本来想存分),也会经过 set 钩子,变成 135 元而不是 135 分。当然这可能造成另一种语义错误,但至少数据本身是自洽的。
更重要的是代码量。原来那个 620 行的实体类,现在 240 行,剩下的全是业务方法。任何新同事翻到这个文件,不会先被三十组 getter/setter 淹没,能直接看到字段和它们的语义约束。
如果你们也在考虑升 8.4 用这些特性,我的建议是:先在**最干净的那个数据模型**上试水,挑一个字段少、调用方少的实体,跑完一轮上线观察。别像我一样拿订单这种核心实体当试验田——话虽如此,也确实因为踩了这些坑,才把该注意的点都摸清楚了。后面改 `OrderItem`、`Refund` 这些类的时候,一套模板套下去,一小时一个,顺得很。
最后提醒一句:把 `composer.json` 里的 `php` 版本约束改到 `>=8.4`,让所有开发者本地环境对齐。我见过不止一次因为某个开发人员的 Docker 镜像没更新,本地跑得动 CI 挂一片的。这类基础工作不起眼,但比任何设计模式都重要。

