如果你写过几年 PHP 项目,一定对实体类里那一大堆 getName()、setName() 方法不陌生。一个简单的数据对象,属性可能就五六个,但为了控制访问和加入一点校验逻辑,getter 和 setter 能轻松堆出上百行代码。更麻烦的是,团队里总有人会图省事直接访问属性,然后你又得在重构时满项目找直接赋值的地方。
PHP 8.4 引入的属性钩子(property hooks)就是从语言层面解决这个问题的。你可以在属性声明后面直接定义 get 和 set 逻辑,外部访问属性时自动走你定义的钩子,看起来像公开属性,实际上每次读写都在你的控制之下。配合同版本带来的无类型克隆(clone with property changes),生成一个基于现有对象的变体也变得异常简洁。本文用一个用户数据对象的实际案例,把这些新特性的用法串一遍。
传统写法的痛点
先看一个最普通的用户实体类,包含 id、姓名、邮箱和注册时间。为了不让外部随便改动邮件格式,我们在 setter 里加了一点验证:
class User
{
private int $id;
private string $name;
private string $email;
private DateTimeImmutable $createdAt;
public function __construct(int $id, string $name, string $email)
{
$this->id = $id;
$this->name = $name;
$this->setEmail($email);
$this->createdAt = new DateTimeImmutable();
}
public function getId(): int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): void
{
$this->name = $name;
}
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email): void
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('邮件格式不正确');
}
$this->email = strtolower($email);
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
}
这类代码几乎每个项目都有。它的缺点不是逻辑复杂,而是信息重复——属性名出现了四遍(声明、getter 方法名、setter 方法名、构造函数赋值),实在没有多少技术含量却占据了大量篇幅。而且 getId() 这种纯粹的读取方法完全就是样板。
属性钩子的基本语法
PHP 8.4 允许你在属性声明的末尾加上一对花括号,里面写 get 和 set。语法类似:
public string $name {
get => $this->name;
set => $this->name = $value;
}
get 钩子被调用时返回属性值,set 钩子中的 $value 代表被赋予的新值。可以只写其中一个钩子,比如只定义 get 不定义 set,则该属性对外只读。另外,钩子里可以写多行逻辑,用 get { ... } 的块语法。
属性钩子背后的属性本身还是正常的属性,只是访问时被拦截了。你可以在构造函数里正常赋值,也会触发钩子。这样,原来那些 getName()、setName() 方法全部可以删掉,外部直接读写 $user->name,但实际执行的是你定义的钩子逻辑。
重构用户对象:用属性钩子重写 User 类
我们用属性钩子把刚才的 User 类重新实现一遍:
class User
{
public function __construct(
public readonly int $id,
public string $name,
public string $email {
set {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('邮件格式不正确');
}
$this->email = strtolower($value);
}
},
public readonly DateTimeImmutable $createdAt = new DateTimeImmutable(),
) {}
}
整个类只剩下一个构造函数,属性声明和钩子全部嵌入在构造函数的参数提升中。$id 和 $createdAt 用 readonly 修饰,没有 set 钩子,自然不可修改。$name 是普通公开属性,没有任何钩子,读写行为与普通属性一致。$email 定义了 set 钩子,只写 set 钩子时 get 钩子会自动生成一个默认的(直接返回属性值)。
使用时和普通属性完全一样:
$user = new User(1, '张三', 'zhangsan@example.com');
echo $user->email; // 输出 zhangsan@example.com
$user->name = '李四'; // 直接赋值,允许
// $user->id = 2; // 错误,readonly 且没有 set 钩子
$user->email = 'Invalid'; // 抛出异常,邮件格式不正确
如果某个属性需要一个格式化的 get 方法,比如返回注册时间的友好格式,可以加一个只有 get 的属性(不存实际数据,只做计算):
public string $createdAtFormatted {
get => $this->createdAt->format('Y-m-d H:i:s');
}
这种方式彻底消灭了 getter/setter 方法,同时保留了数据验证和转换能力。而且 $user->email 比 $user->getEmail() 读起来自然得多,和模板引擎、序列化工具配合也不需要配置属性映射。
无类型克隆:快速生成对象变体
另一个常见需求是基于已有对象创建一个新对象,只改动一两个字段。比如用户修改了邮箱,需要创建一个新的 User 对象(保持不可变性)。以往的做法是:
$newUser = clone $user;
$newUser->setEmail('new@example.com'); // 如果是 readonly 属性,这条路根本走不通
PHP 8.4 的 clone with 语法让你在克隆时直接修改属性,哪怕它们是 readonly:
$newUser = clone $user with {
email: 'new@example.com',
};
这个语法不调用 set 钩子——它直接修改对象的底层属性值,因此可以绕过 readonly 限制,也可以绕过你在 set 钩子中定义的逻辑。这对于创建“不可变对象的新版本”非常合适。如果希望在 with 操作中也进行验证,需要自己在 __clone 魔术方法中处理,但目前 with 的语义就是不做钩子触发。
同时修改多个属性:
$updatedUser = clone $user with {
name: '王五',
email: 'wangwu@example.com',
};
这个特性让不可变数据对象的创建变得更简洁。结合属性钩子,你可以设计出对外只读、通过 clone with 修改的数据模型,完整保留每一次变更的历史引用。
实战:一个简单的值对象集合
下面的例子展示一个地址对象,包含国家和城市,要通过 with 产生修改后的副本:
class Address
{
public function __construct(
public readonly string $country,
public readonly string $city,
) {}
}
$addr = new Address('中国', '北京');
$newAddr = clone $addr with { city: '上海' };
echo $newAddr->city; // 上海
以前如果要让 $city 可变,要么去掉 readonly,要么写一个 withCity 方法返回新对象。现在 clone with 直接解决了这个需求,不需要额外方法。
属性钩子的几个限制和注意事项
不能与 readonly 同时定义 set 钩子。 这是废话——readonly 意味着不能写,当然不能有 set 钩子。但如果一个属性有 set 钩子,它就不能声明为 readonly。
钩子内不能调用自身触发递归。 在 set 钩子里直接赋值 $this->email = ... 是会再次触发 set 钩子的,造成无限递归。解决办法是使用 $this->email 对应的底层属性访问。目前的设计中,钩子内部通过 $this->属性名 访问的是原始属性值,这个细节在文档里有说明,但实际写的时候要注意,最好先在钩子外测试。
序列化与反射。 属性钩子对序列化透明,serialize() 和 var_export() 只关心属性值。反射 API 也更新了,可以获取某个属性是否定义了钩子。
覆盖钩子。 子类可以覆盖父类属性的钩子,这与覆盖方法类似,需要通过 parent::$属性名::get() 调用父类钩子。
这些新特性要升级到什么程度?
属性钩子和无类型克隆针对的是写过大量数据对象的那批开发者。如果你的项目里实体类很少,都是一些简单的关联数组,可能感觉不太明显。但如果你维护的代码库里有数十个模型类,每个都带着 getter/setter 和 withXXX 方法,这两项特性可以让文件缩水三分之一甚至更多。更重要的是,读代码时不再需要在属性和方法之间反复跳转,所有关于属性的逻辑都在声明点一目了然。
从社区反馈看,属性钩子被认为是 PHP 近几个版本中最实用的语法改进之一,类似于箭头函数对闭包的简化。它不改变 PHP 的核心性能,但让写代码这件事变得轻巧了不少。
总结
属性钩子把 getter/setter 逻辑从方法搬到了属性声明上,clone with 让不可变对象的修改变得直接。两者结合,PHP 的数据对象编写方式向 C#、Kotlin 等语言看齐,用更少的代码做更多的事。本文的用户类和地址类案例覆盖了最常见的场景,你可以把它们直接迁移到自己的实体模型里,把那些重复的 getXxx() 方法删掉一部分,得到的不仅是代码量减少,还有更清晰的模型表达。

