getXxx()和setXxx()方法,或者用魔术方法__get、__set来避免,但那样又会丢掉类型提示和IDE支持。Property Hooks恰好解决了这个长久以来的痛点:你可以在属性定义本身旁边,直接声明get和set的逻辑,整个代码量缩水不少,可读性反而上来了。
这篇文字就结合一个用户模型的改造过程,把Property Hooks的用法、虚拟属性、以及和构造器提升、只读属性的配合方式完整跑一遍,全部代码都能在PHP 8.4下直接运行。
传统方式:被getter/setter淹没的模型
先看看过去我是怎么定义用户类的。为保护password属性,通常不允许直接赋值铭文密码,而是通过一个setPassword方法来哈希:
class User {
private string $passwordHash;
private string $email;
private DateTimeImmutable $createdAt;
public function __construct(string $email, string $plainPassword) {
$this->email = $email;
$this->passwordHash = password_hash($plainPassword, PASSWORD_BCRYPT);
$this->createdAt = new DateTimeImmutable();
}
public function getPasswordHash(): string {
return $this->passwordHash;
}
public function setPassword(string $plainPassword): void {
$this->passwordHash = password_hash($plainPassword, PASSWORD_BCRYPT);
}
public function getEmail(): string {
return $this->email;
}
public function getCreatedAt(): DateTimeImmutable {
return $this->createdAt;
}
// ... 还有一堆业务方法
}
这种写法在属性多的时候特别冗长。而且调用方需要用$user->getCreatedAt()而不是直接$user->createdAt,无形中增加了许多括号噪音。
Property Hooks初探:钩子即方法
属性钩子的思路很简单:在属性声明后面加上用花括号包裹的get和set块,分别定义读、写该属性时执行的操作。它看起来有点像C#的属性,但语法保持了PHP的风格。
我们把上面的$passwordHash改为带钩子的属性,同时保留存储值(需要一个后台字段)。PHP 8.4允许使用$this->实际属性名来存取原始值,但更推荐直接为钩子属性定义一个私有的真实存储属性。实践中常用方式是:将属性定义为private且带钩子,钩子内部读写该属性自身。不过要注意避免递归——在get钩子里不能再读自己,在set钩子里不能直接给自己赋值,需要用$value参数。
重构用户模型:第一步,哈希密码的set钩子
我们从密码入手。属性$password现在支持get和set钩子:
class User {
private string $password {
set (string $plainPassword) {
$this->password = password_hash($plainPassword, PASSWORD_BCRYPT);
}
get {
return $this->password;
}
}
public function __construct(string $email, string $plainPassword) {
$this->email = $email;
$this->password = $plainPassword; // 触发set钩子,自动哈希
$this->createdAt = new DateTimeImmutable();
}
private string $email;
private DateTimeImmutable $createdAt;
}
注意set钩子里我们用的是$this->password = ...来真正存储哈希值,这实际上又触发了自身的set钩子?不,在钩子内部对自身属性的直接赋值是允许的,并且它不会再次触发钩子,而是直接写入属性的存储。这是编译器专门处理过的。
外部调用$user->password = 'new_plain';就会自动哈希并存储,而echo $user->password;会返回哈希值。我们成功去掉了setPassword和getPasswordHash两个方法。
第二步:虚拟属性——组合字段而不额外存储
用户类通常会有全名由姓和名拼接而成。过去我们会定义一个getFullName()方法。现在可以用虚拟属性实现:没有存储空间的属性,只定义get钩子(或加上set),根本不需要在类内部存值。
class User {
// ... 其他属性
public string $fullName {
get {
return $this->firstName . ' ' . $this->lastName;
}
}
public function __construct(
private string $firstName,
private string $lastName,
// ...其他参数
) {}
}
这里我们使用了构造器提升(private string $firstName直接在构造器参数),同时定义了虚拟属性$fullName,它没有后台字段,完全由get钩子动态计算。外部可以像访问普通属性一样读取$user->fullName,而且因为是只读的(没有set钩子),TypeEnforcement会自动阻止赋值。
第三步:带校验的set钩子与类型收窄
电子邮件地址需要格式校验。我们为$email加上set钩子,在赋值时过滤无效值:
private string $email {
set (string $value) {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('无效的电子邮箱');
}
$this->email = strtolower($value);
}
get => $this->email;
}
这里get使用了箭头函数简写,等价于get { return $this->email; }。当尝试给$email赋无效值时,异常会在赋值时刻抛出,比事后验证更即时。
第四步:只读属性与钩子的配合
$createdAt通常只在对象构造时设置一次,之后不允许修改。PHP 8.1起支持readonly修饰符,但readonly属性不能定义钩子。不过我们可以用钩子实现“仅构造一次”的语义:在set钩子中检查是否已赋值,只允许首次写入。
private ?DateTimeImmutable $createdAt = null {
set (DateTimeImmutable|string $value) {
if ($this->createdAt !== null) {
throw new LogicException('创建时间不可修改');
}
$this->createdAt = $value instanceof DateTimeImmutable
? $value
: new DateTimeImmutable($value);
}
get => $this->createdAt;
}
构造函数里我们传入一个DateTimeImmutable或字符串,后续尝试修改会抛出异常,达到了readonly的效果,同时还能处理类型转换。
完整用户模型与应用示例
把所有改造整合到一起,得到最终的User类:
class User {
public string $fullName {
get => $this->firstName . ' ' . $this->lastName;
}
private string $email {
set (string $value) {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('无效邮箱');
}
$this->email = strtolower($value);
}
get => $this->email;
}
private string $password {
set (string $plainPassword) {
$this->password = password_hash($plainPassword, PASSWORD_BCRYPT);
}
get => $this->password;
}
private ?DateTimeImmutable $createdAt = null {
set (DateTimeImmutable|string $value) {
if ($this->createdAt !== null) {
throw new LogicException('创建时间不可修改');
}
$this->createdAt = $value instanceof DateTimeImmutable
? $value
: new DateTimeImmutable($value);
}
get => $this->createdAt;
}
public function __construct(
private string $firstName,
private string $lastName,
string $email,
string $plainPassword,
) {
$this->email = $email;
$this->password = $plainPassword;
$this->createdAt = 'now'; // 取当前时间
}
// 业务方法
public function verifyPassword(string $plainPassword): bool {
return password_verify($plainPassword, $this->password);
}
}
使用起来非常直观:
$user = new User('张', '三', 'zhangsan@example.com', 'secret123');
echo $user->fullName; // 张三
$user->email = 'NewEmail@Example.com'; // 自动转小写
echo $user->email; // newemail@example.com
// 尝试修改创建时间会抛出异常
// $user->createdAt = 'yesterday'; // LogicException
// 密码自动哈希,验证
var_dump($user->verifyPassword('secret123')); // true
性能注意和小坑
属性钩子本质上会生成对应的方法调用,所以在极高频访问的场景下可能有轻微性能损耗。但对于绝大多数业务代码,这点开销完全可以忽略不计。更重要的一点是,钩子内的递归保护:在set钩子里对$this->属性名赋值是直接写存储,不会导致无限循环;但如果在get钩子里又去读取同一属性,就会触发递归,导致致命错误。因此get钩子中只能返回后台字段或者引用其他属性。
另外,isset和unset对带钩子的属性的行为也可以通过实现isset和unset钩子来定义,不过大部分场景默认行为就够了。
何时用属性钩子,何时仍用传统方法
属性钩子适合那些单纯围绕某个字段的存取逻辑:类型过滤、自动转换、延迟加载、格式美化。而涉及多个字段协同、需要参数或者副作用很大的操作,还是应该用传统方法保持意图清晰。比如verifyPassword就需要明文参数,没必要去改写password的get钩子。
总结
从getPasswordHash()和setPassword()到天然的属性访问,PHP 8.4的Property Hooks把对象封装推到了一个更舒服的平衡点:既有直接操作属性的便利,又不丢失控制逻辑。在用户模型这个典型场景里,我们用几行钩子替换了几十个样板字符,代码的意图变得一目了然。
如果你的项目刚刚升级或者即将迁移到PHP 8.4,建议把那些被大量get和set簇拥的实体类拿出来,用属性钩子重写一遍。你很快会发现,面向对象的封装性和代码的简洁性,终于可以同时拥有了。

