很长一段时间里,PHP 的对象属性只有两种活法:要么是裸的 public 属性,读写都快,但任何约束都放不进去;要么换成 getter / setter,约束是有了,可调用方得从 $user->name 改成 $user->getName(),一改就是全局搜索替换。
中间那条路是 __get / __set 魔术方法。它确实保留了属性语法,但代价是把所有属性塞进同一个入口,IDE 补全归零、静态分析器基本失明,排查问题时断点还得先猜是哪个属性触发的。用过的人大多不想再用第二次。
PHP 8.4 引入的 Property Hooks(属性钩子)把这条路补上了:属性本身可以挂 get 和 set 两段逻辑,外部调用语法完全不变,内部却能插入任意代码。这篇文章不讲语法糖有多甜,直接用一个真实场景把它跑一遍。
一、先花两分钟把语法摸清楚
最小形态长这样:
final 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;
}
}
}
几个必须记住的点:
get => 表达式是简写形式,逻辑长了就换成花括号代码块。set的参数名可以自定义,不写类型时默认沿用属性的类型。写宽一点的类型也是允许的,比如属性是string,set 参数收string|Stringable。- 只定义
set不定义get,读取时会落到属性自己的存储上;两边都不定义具体实现、只引用别的字段,那这个属性就是纯虚拟属性,不占内存。 - 钩子内部访问
$this->同一个属性名,拿到的是底层存储值,不会递归调用钩子。这是整个特性最关键的一条规则,后面还会反复用到。
二、案例背景:一个总是被写坏的用户资料对象
假设我们在做一个 API 项目,需要一个 UserProfile。业务上对它有一堆零散要求:
- 用户名、邮箱在赋值时就必须合法,不能等到落库才报错;
- 密码只允许写入,读取应当被明确拒绝,避免日志里打出明文或哈希;
- 显示名没设置过就从邮箱前缀推导,但推导结果要缓存,不能每次读都算一遍;
- 头像地址依赖邮箱,邮箱一变就作废重算;
- 用户 ID 外部只读,只有类内部能改。
传统写法大概率会变成一堆 setter 加两个私有缓存字段,再配一个 refreshDerivedFields() 到处调用。我们看看用属性钩子能压成什么样。
完整实现
<?php
declare(strict_types=1);
final class UserProfile
{
/** 对外只读,仅类内可写 */
public private(set) int $id;
private string $passwordHash = '';
private ?string $displayNameCache = null;
private ?string $gravatarCache = null;
public function __construct(int $id, string $email)
{
$this->id = $id;
$this->email = $email;
}
public string $email {
set (string $value) {
$normalized = strtolower(trim($value));
if (filter_var($normalized, FILTER_VALIDATE_EMAIL) === false) {
throw new InvalidArgumentException("邮箱格式不合法:{$value}");
}
$this->email = $normalized;
// 依赖邮箱的缓存整体作废
$this->displayNameCache = null;
$this->gravatarCache = null;
}
}
public string $displayName {
get => $this->displayNameCache ??= $this->deriveDisplayName();
set (string $value) {
$trimmed = trim($value);
if ($trimmed === '') {
throw new InvalidArgumentException('显示名不能为空白');
}
$this->displayNameCache = $trimmed;
}
}
public string $gravatarUrl {
get => $this->gravatarCache ??= sprintf(
'https://www.gravatar.com/avatar/%s?d=identicon&s=80',
hash('sha256', $this->email)
);
}
public string $password {
get => throw new LogicException('密码是只写属性,不允许读取');
set (string $value) {
if (mb_strlen($value) < 8) {
throw new InvalidArgumentException('密码长度至少 8 位');
}
$this->passwordHash = password_hash($value, PASSWORD_ARGON2ID);
}
}
public function verifyPassword(string $plain): bool
{
return password_verify($plain, $this->passwordHash);
}
private function deriveDisplayName(): string
{
$local = strstr($this->email, '@', true);
return ($local === false || $local === '') ? 'anonymous' : $local;
}
}
逐块拆解
id 的只读约束用了一句话:public private(set) int $id;。这是 PHP 8.4 的不对称可见性——读是 public,写是 private。外部写它会直接抛 Error,不需要任何 readonly 技巧,也不需要把整个属性私有化再补一个 getter。
email 只写了 set 钩子,没有写 get。读取时 PHP 直接返回底层存储的值,也就是归一化之后的邮箱。set 钩子里做了三件事:trim + 转小写、格式校验、清空下游缓存。注意最后那两行——这正是过去最容易漏掉的:邮箱变了,用邮箱派生的显示名和头像必须一起失效。把这段逻辑放在 set 钩子里,意味着只要有人改邮箱,缓存就一定跟着清,靠的是语言机制而不是开发者自觉。
displayName 是纯虚拟属性,真正的数据存在 $displayNameCache 里。get 里用 ??= 实现了惰性计算 + 缓存:第一次读会推导并写回缓存,第二次读直接命中。set 里做了空白校验,顺手把推导默认值的可能性关掉。
gravatarUrl 只有 get 钩子,完全是派生数据。它同样做了缓存,并且缓存由 email 的 setter 负责清空。调用方拿到的永远是最新的地址,但只有在真正需要时才算一次 sha256。
password 是只写属性。get 钩子直接 throw,这在 PHP 8 里是合法的,因为 throw 是表达式。set 钩子承担长度校验和哈希。这里有个实际收益:任何试图把 password 打印出来的代码——包括 var_dump、日志序列化、json_encode——都会明确报错,而不是悄悄泄露哈希。同时对象自己保管 $passwordHash,外部拿不到。
三、跑一遍看看效果
$profile = new UserProfile(42, ' Alice@Example.COM ');
echo $profile->email; // alice@example.com
echo $profile->displayName; // alice
echo $profile->displayName; // alice(第二次走缓存)
echo $profile->gravatarUrl; // https://www.gravatar.com/avatar/...
$profile->displayName = 'Alice Wang';
echo $profile->displayName; // Alice Wang
$profile->email = 'alice.wang@example.com';
echo $profile->displayName; // alice.wang(缓存已随邮箱失效)
echo $profile->gravatarUrl; // 头像地址已更新
$profile->password = 'correct-horse-battery';
var_dump($profile->verifyPassword('correct-horse-battery')); // true
// 下面三行都会抛异常
$profile->email = 'not-an-email'; // InvalidArgumentException
$profile->password = '123'; // InvalidArgumentException
echo $profile->password; // LogicException
// 这一行是 Error:set 可见性为 private
$profile->id = 100;
所有约束都在赋值那一刻生效,而且调用方看到的是普通属性语法,没有任何 setXxx() 的痕迹。
四、几个特别容易踩的坑
1. 钩子里访问自身属性拿到的是存储值
这是最反直觉的一条,也是最容易写错的一条。看下面这个错误示范:
public string $email {
set (string $value) {
// 这里 $this->email 拿到的是底层存储,不是又一次进入 set
// 所以这一行是安全的
$this->email = strtolower(trim($value));
}
}
很多人的第一反应是”这不就无限递归了吗”。不会。PHP 在钩子内部对同名属性的访问会绕过钩子,直接读写底层存储。理解这点之后,你会发现可以放心地在钩子里读写自己。
但反过来,这也意味着你没法在钩子里调用”另一个钩子”。如果你需要复用逻辑,就老老实实抽一个 private 方法出来。
2. 虚拟属性需要一个真实的私有字段来落数据
只要钩子内部碰了 $this->同名属性,这个属性就有存储;只碰别的字段,它就是虚拟属性。displayName 和 gravatarUrl 都属于后者,它们的真实数据在 $displayNameCache 和 $gravatarCache 里。命名上建议明确区分,避免读代码的人误以为 $this->displayName 有存储。
3. 序列化和调试工具的行为要亲自验证
虚拟属性不出现在 get_object_vars() 的结果里,而 json_encode 是否包含它、var_export 会不会触发 get 钩子,跟具体版本和工具链有关。如果你的 DTO 要直接返回给前端,建议在项目里固定一套序列化方式,比如显式实现 JsonSerializable,把要暴露的字段列清楚,而不是依赖默认行为。
4. 缓存失效的责任要写清楚
属性钩子的好处是把校验和派生绑在了属性上,但缓存失效这件事,语言帮不了你。上面的例子里,email 的 setter 主动清空了 displayNameCache 和 gravatarCache。如果以后新增了 mailDomain 这样的派生属性,一定要记得回到 email 的 setter 里补一行。这不是属性钩子独有的问题,但把逻辑集中在一个 setter 里,至少比散落在三个地方要好找得多。
五、什么时候该用,什么时候别用
属性钩子适合的场景很明确:约束和派生逻辑天然属于某一个字段。校验邮箱格式、把密码转成哈希、从邮箱推导头像地址,都属于这一类。把逻辑写在字段旁边,比写在三十行之外的 setter 里更好维护。
不该用的场景同样明确:
- 需要多个字段联动的约束,比如”开始时间必须早于结束时间”。这类逻辑跨字段,写在任何一个钩子里都会显得别扭,老老实实写一个
validate()方法更清楚。 - 会触发 IO 的 get 钩子。比如一个
get里查数据库。属性读写在调用方看来是零成本的,一旦里面藏了 IO,调试时会非常痛苦。惰性计算和惰性加载是两回事,后者请用显式方法。 - 需要出现在序列化结果里的热字段。如果这个字段每秒被读几万次,而你只是想加个类型转换,那用
set钩子在写入时转换一次就够了,不要给读取路径加钩子。
另外提醒一句:属性钩子是 PHP 8.4 起才有的语法,老项目升级前先确认 CI 里的 PHP 版本、以及像 PHPStan / Psalm 这类静态分析工具是否已经跟上了对该语法的解析,否则你的类型检查会在钩子处直接失效。
六、小结
Property Hooks 解决的不是”能不能做到”的问题——用 getter / setter 什么都能做到。它解决的是把约束放回它该在的地方。校验逻辑贴在字段上,调用方用属性语法,IDE 和静态分析仍然认得出类型,缓存失效的触发点也集中在同一处。
上面这个 UserProfile 如果改成传统写法,至少会多出四个 setter、两个 getter、一个私有的缓存刷新方法,以及若干处”改完邮箱记得调用 refresh”的口头约定。现在这些全都收进了属性声明里。这大概是 PHP 8.4 里最值得在业务代码中立刻用起来的特性。

