PHP 8.4 带来的新东西不少,但真正会改变日常写法的,我觉得只有一个:属性钩子(property hooks)。
以前给属性加校验,路径基本是固定的——属性设成 private,另外写一对 getX / setX,然后再满项目找直接访问属性的地方全改掉。钩子把这套流程压缩成了在属性声明后面加一段代码块。听起来像小改动,但写到第五十个实体类的时候,差别就出来了。
下面直接拿一个电商订单模型动手改。命令行的东西不多,重点是代码前后的对比。
先看看重构前的样子
这是一个很典型的订单行类,数量必须大于 0,单价不能为负,小计是算出来的:
<?php
declare(strict_types=1);
namespace Shop;
use InvalidArgumentException;
final class OrderLine
{
private int $quantity;
private int $unitPriceCents;
public function __construct(
private readonly string $sku,
int $quantity,
int $unitPriceCents,
) {
$this->setQuantity($quantity);
$this->setUnitPriceCents($unitPriceCents);
}
public function getSku(): string
{
return $this->sku;
}
public function getQuantity(): int
{
return $this->quantity;
}
public function setQuantity(int $quantity): void
{
if ($quantity < 1) {
throw new InvalidArgumentException("数量必须大于 0,收到 {$quantity}");
}
$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 getSubtotalCents(): int
{
return $this->quantity * $this->unitPriceCents;
}
}
数一下,五十行上下。这里面真正表达业务规则的只有两个 if 和一个乘法,剩下的全是搬运工。
set 钩子:把校验挪到属性声明上
先把数量这个属性改掉:
public int $quantity {
set (int $value) {
if ($value < 1) {
throw new InvalidArgumentException("数量必须大于 0,收到 {$value}");
}
$this->quantity = $value;
}
}
属性类型还是 int,公开可见,外部依然可以写 $line->quantity = 3;。但任何一次写入都会先进到 set 块里。校验没丢,只是位置变了。
这里有个细节得说清楚:在 set 块内部给 $this->quantity 赋值,不会再次触发钩子。PHP 知道当前正在执行这个属性的 set 逻辑,会直接把值写进底层存储。否则这段代码自己就把自己递归死了。刚上手的时候容易在这句话上纠结半天,记住结论就行。
同样的写法套到单价上:
public int $unitPriceCents {
set (int $value) {
if ($value < 0) {
throw new InvalidArgumentException('单价不能为负数');
}
$this->unitPriceCents = $value;
}
}
get 钩子:把计算方法变成“虚拟属性”
小计这种东西最尴尬。它不该被单独存一份,因为数量或单价一变它就过期了;但当方法写又得记成 getSubtotalCents(),读起来跟普通取值没区别,调用方总得留神有没有括号。
属性钩子给了第三种选择——只定义 get,不给存储:
public int $subtotalCents {
get => $this->quantity * $this->unitPriceCents;
}
这种没有底层存储、每次读取时现算的属性,官方叫虚拟属性(virtual property)。上面用的是箭头写法,表达式的结果就是返回值,适合一行能算完的场景。
稍微复杂一点的,用花括号块:
public int $taxCents {
get {
$rate = 0.13;
return (int) round($this->subtotalCents * $rate);
}
}
注意这里读的是 $this->subtotalCents,也就是读另一个虚拟属性。虚拟属性之间互相当积木,链子拉多长都行,只要别搞成循环引用。
还有一点值得单拎出来:虚拟属性默认就是只读的,外部写不了。这句话不是靠什么开关实现的,而是因为它压根没有存储槽——你没定义 set,PHP 就不知道这个值该往哪搁。真试着写的话,会直接抛错。
非对称可见性:对外只读,对内可写
这是和属性钩子同批落地的另一个特性,配合起来用特别顺手。
设想订单状态:外部只能看,只有类自己内部的流程方法能改。以前怎么做?private 属性加一个 public 的 getter,又是一坨样板。现在直接在可见性上分家:
public private(set) string $status = 'draft';
private(set) 的意思是:读取按 public 走,写入按 private 走。类外面能拿到状态,但改不了;类内部的方法随便赋值。
把它和虚拟属性拼到一起,就凑出了一个完整的订单聚合类:
<?php
declare(strict_types=1);
namespace Shop;
use LogicException;
final class Order
{
/** @var list<OrderLine> */
private array $lines = [];
public private(set) string $status = 'draft';
public int $totalCents {
get {
$sum = 0;
foreach ($this->lines as $line) {
$sum += $line->totalCents;
}
return $sum;
}
}
public function addLine(OrderLine $line): void
{
if ($this->status !== 'draft') {
throw new LogicException('订单已提交,不能再追加订单行');
}
$this->lines[] = $line;
}
public function submit(): void
{
if ($this->lines === []) {
throw new LogicException('空订单不能提交');
}
$this->status = 'submitted';
}
public function lines(): array
{
return $this->lines;
}
}
调用端跑一遍:
$order = new Order();
$order->addLine(new OrderLine('KEYB-87', 2, 12900));
$order->addLine(new OrderLine('MOUS-11', 1, 4900));
$order->submit();
echo $order->totalCents;
$order->status = 'paid';
前几行都没问题。最后一行会直接报错,提示这个属性的写入权限是 private。以前为了拿到这种保护,得写一个 public 的 getStatus() 加一个 private 的 markStatus(),现在两行声明解决。
重构后的完整订单行
把前面拆开讲的片段收回来,这就是最终的 OrderLine:
<?php
declare(strict_types=1);
namespace Shop;
use InvalidArgumentException;
final class OrderLine
{
public int $quantity {
set (int $value) {
if ($value < 1) {
throw new InvalidArgumentException("数量必须大于 0,收到 {$value}");
}
$this->quantity = $value;
}
}
public int $unitPriceCents {
set (int $value) {
if ($value < 0) {
throw new InvalidArgumentException('单价不能为负数');
}
$this->unitPriceCents = $value;
}
}
public int $subtotalCents {
get => $this->quantity * $this->unitPriceCents;
}
public int $taxCents {
get => (int) round($this->subtotalCents * 0.13);
}
public int $totalCents {
get => $this->subtotalCents + $this->taxCents;
}
public function __construct(
public readonly string $sku,
int $quantity,
int $unitPriceCents,
) {
$this->quantity = $quantity;
$this->unitPriceCents = $unitPriceCents;
}
}
对比一下开头那版:属性、校验、计算全在同一处,中间没有任何 getXxx / setXxx 介入。构造函数里那两行赋值照旧会走钩子,所以非法数据在对象刚出生的时候就被拦下了,不用等到第一次调用 setter。
顺手补两个测试
行为完全没变,所以原有测试应该原样通过。不过钩子带来了一些以前测不到的角度,值得补上:
use InvalidArgumentException;
use ShopOrderLine;
use PHPUnitFrameworkTestCase;
final class OrderLineTest extends TestCase
{
public function testConstructorAlsoRunsValidation(): void
{
$this->expectException(InvalidArgumentException::class);
new OrderLine('SKU-1', 0, 100);
}
public function testQuantityCanBeChangedThroughPublicWrite(): void
{
$line = new OrderLine('SKU-1', 1, 100);
$line->quantity = 5;
self::assertSame(5, $line->quantity);
}
public function testVirtualPropertiesRecomputeOnEveryRead(): void
{
$line = new OrderLine('SKU-1', 3, 1999);
self::assertSame(5997, $line->subtotalCents);
$line->quantity = 4;
self::assertSame(7996, $line->subtotalCents);
}
public function testTotalIsNotWritable(): void
{
$line = new OrderLine('SKU-1', 1, 100);
$this->expectException(Error::class);
$line->totalCents = 999;
}
}
第三个测试是重点:它验证了小计不是快照,而是每次读取现算。如果哪天有人手贱把它改成普通属性,这条测试会立刻炸。
几个必须提前知道的坑
虚拟属性不进序列化结果。 因为没有底层存储,json_encode($line) 和 get_object_vars($line) 都不会带上 subtotalCents、taxCents、totalCents。做 API 输出的时候不能指望它们自动出现,得在 jsonSerialize() 或者 DTO 组装那一步显式写出来。这一点从传统方法迁移过来的人最容易翻车。
构造函数里的赋值照样走钩子。 这既是好事也是坑。好事是校验覆盖完整;坑在于,如果你的钩子里引用了另一个还没赋值的属性,就会读到未初始化的状态。赋值顺序需要自己心里有数。
低于 8.4 的环境连解析都过不去。 这不是运行时才报错的问题,而是语法层面的东西。老版本 PHP 读到 public int $quantity { 这一行会直接抛语法错误。所以 composer.json 里的 php 约束要改成 "^8.4",CI 镜像也得同步升上去,否则部署时才炸就晚了。
别在 get 钩子里干重活。 每次读取都会重新执行一遍。你要是往里塞个数据库查询或者远程调用,循环里读几次就查几次。真需要缓存,就在类内部自己存一份,钩子里返回缓存值。
什么时候别用钩子
属性钩子解决的是「读写时带一点轻量规则」的问题。如果你的 set 里要发消息、要写日志、要联动另外三个对象的状态,那它就不是一个属性了,是一个业务动作,老老实实写个正经方法,起个能说明意图的名字。
我的建议是,先挑下一个值对象或者实体试,别一上来就大规模翻新。等团队里所有人都习惯了 $line->quantity = 0 会抛异常这件事,再往外铺。

