PHP 8.4 在 2024 年 11 月正式发布,这个版本带来的两个重磅特性——属性钩子和非对称可见性——直接改变了 PHP 类的编写方式。过去定义一个稍微复杂的属性就得配上一对 getter 和 setter,属性多了之后类文件一大半都是这些样板代码。属性钩子让属性的读写行为直接挂在属性本身,非对称可见性让读和写的访问权限可以分开控制。这篇文章用一个完整的用户管理案例,把这两个特性从语法到实战都讲清楚,所有代码都可以在 PHP 8.4 环境下直接运行。
一、先看痛点:传统写法的样板代码到底有多少
假设你要定义一个用户实体,需求很简单:用户名不可为空、邮箱格式要校验、密码只能设置不能读取、创建时间自动生成且不可修改。用 PHP 8.3 以前的写法,这个类长这样:
class User
{
private string $name;
private string $email;
private string $password;
private DateTimeImmutable $createdAt;
public function __construct(string $name, string $email, string $password)
{
$this->setName($name);
$this->setEmail($email);
$this->password = password_hash($password, PASSWORD_BCRYPT);
$this->createdAt = new DateTimeImmutable();
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): void
{
if (trim($name) === '') {
throw new InvalidArgumentException('用户名不能为空');
}
$this->name = trim($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 verifyPassword(string $input): bool
{
return password_verify($input, $this->password);
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
}
三个业务属性,配了六个方法,外加一个构造函数。属性越多,这个比例越夸张。而且 getXxx 和 setXxx 的命名虽然约定俗成,但本质上是在用方法模拟属性的读写行为——属性本身是公开不了的,因为你需要校验逻辑,但又不想放弃访问控制的简洁性。
PHP 8.4 的属性钩子直接把这个矛盾化解了。你可以在属性上定义 get 和 set 钩子,读属性时自动走 get 逻辑,写属性时自动走 set 逻辑。调用方用的还是 $user->name 这种直观的属性语法,而不是 $user->getName()。
二、属性钩子语法速览
属性钩子是挂载在属性定义上的代码块,用 get 和 set 关键字标识:
class Product
{
// 带 get 钩子的属性:每次读取时触发
public string $displayPrice {
get => '¥' . number_format($this->price, 2);
}
// 带 set 钩子的属性:每次写入时触发
public string $name {
set(string $value) {
if (trim($value) === '') {
throw new InvalidArgumentException('名称不能为空');
}
$this->name = trim($value);
}
}
// 同时有 get 和 set 的属性
public float $price {
get => $this->price;
set(float $value) {
if ($value price = $value;
}
}
private float $price; // 幕后存储字段
}
几个关键规则需要记住:
- 有钩子的属性不能和普通属性混用。如果一个属性定义了钩子,它就不能再在构造函数里直接赋值
$this->prop = value(除非 set 钩子本身会处理)。 - get 钩子必须返回值,set 钩子不返回值。get 的简写形式用
=>箭头直接返回表达式,也可以写成完整的代码块get { ... }。 - 幕后存储。如果属性的 get 和 set 都需要操作同一个存储位置,你需要在类里定义一个同名的私有属性作为实际存储。这是目前 PHP 属性钩子设计里的一个特点——钩子本身不自动提供存储。
- 只读属性可以用 get 钩子配合
readonly修饰符吗?不能。如果一个属性有钩子,就不能再加readonly。但你可以只定义 get 钩子而不定义 set 钩子,这自然就是一个只读属性。
三、非对称可见性:读写权限分开控制
PHP 8.4 的另一个重要特性是非对称可见性。简单说就是你可以让一个属性的读权限和写权限不一样。语法是 public get protected set 或 public get private set:
class Order
{
// 任何人都能读取,但只有子类和自身能修改
public string $status {
get => $this->status;
protected set(string $value) {
$allowed = ['pending', 'paid', 'shipped', 'completed'];
if (!in_array($value, $allowed, true)) {
throw new InvalidArgumentException("无效的订单状态: {$value}");
}
$this->status = $value;
}
}
// 任何人都能读取,只有自身能修改
public DateTimeImmutable $createdAt {
get => $this->createdAt;
private set => $this->createdAt;
}
private string $status;
private DateTimeImmutable $createdAt;
}
这个特性在实体类里特别好用。比如订单金额,外部任何代码都能读取,但只能通过专门的 markAsPaid() 方法来修改。以前这种需求要么把属性设成 private 然后写一堆 getter,要么靠文档和代码审查来约束。非对称可见性让这个约束在语法层面就落实了。
四、实战:用 PHP 8.4 重写用户管理系统
回到开头的用户实体。用属性钩子和非对称可见性重写之后,代码的变化会非常明显。
4.1 用户实体类
class User
{
// 用户名:外部可读写,写入时自动 trim 并校验
public string $name {
get => $this->name;
set(string $value) {
$trimmed = trim($value);
if ($trimmed === '') {
throw new InvalidArgumentException('用户名不能为空');
}
$this->name = $trimmed;
}
}
// 邮箱:外部可读写,写入时自动转小写并校验
public string $email {
get => $this->email;
set(string $value) {
$validated = filter_var($value, FILTER_VALIDATE_EMAIL);
if (!$validated) {
throw new InvalidArgumentException("邮箱格式不正确: {$value}");
}
$this->email = strtolower($validated);
}
}
// 密码:外部不能读取不能写入,只能通过构造函数设置
private string $password;
// 创建时间:外部可读,但外部不能修改
public DateTimeImmutable $createdAt {
get => $this->createdAt;
private set => $this->createdAt;
}
// 最后登录时间:外部可读,只能内部更新
public ?DateTimeImmutable $lastLoginAt {
get => $this->lastLoginAt;
private set => $this->lastLoginAt;
} = null;
// 状态:外部可读,protected set 允许子类修改
public string $status {
get => $this->status;
protected set(string $value) {
$allowedValues = ['active', 'inactive', 'suspended'];
if (!in_array($value, $allowedValues, true)) {
throw new InvalidArgumentException("无效的用户状态: {$value}");
}
$this->status = $value;
}
} = 'active';
// 幕后存储字段
private string $name;
private string $email;
private ?DateTimeImmutable $lastLoginAt = null;
private string $status = 'active';
private DateTimeImmutable $createdAt;
public function __construct(string $name, string $email, string $password)
{
$this->name = ''; // 先初始化幕后字段,否则 set 钩子里的 $this->name 未定义
$this->email = '';
$this->createdAt = new DateTimeImmutable();
// 通过属性赋值触发 set 钩子进行校验
$this->name = $name;
$this->email = $email;
$this->password = password_hash($password, PASSWORD_BCRYPT);
}
// 验证密码的方法保留,毕竟它是一段业务逻辑不是单纯的属性读写
public function verifyPassword(string $input): bool
{
return password_verify($input, $this->password);
}
// 记录登录时间:内部修改
public function recordLogin(): void
{
$this->lastLoginAt = new DateTimeImmutable();
}
// 封禁用户:通过 protected set 允许子类也能操作
public function suspend(): void
{
$this->status = 'suspended';
}
public function activate(): void
{
$this->status = 'active';
}
}
重写之后,这个类里不再有 getName、setName、getEmail、setEmail 这些方法,取而代之的是挂在属性上的 get 和 set 钩子。调用方的写法从
$user->setName('张三');
echo $user->getEmail();
变成了
$user->name = '张三';
echo $user->email;
语义上更自然,而校验逻辑完全没有丢失——$user->name = '' 仍然会抛出异常,因为 set 钩子里的校验在起作用。
4.2 使用示例
// 创建用户
$user = new User('张三', 'Zhangsan@Example.com', 'secret123');
// 像操作普通属性一样读写,校验逻辑自动生效
echo $user->name; // "张三"
echo $user->email; // "zhangsan@example.com"(自动转小写)
// 读取只读属性
echo $user->createdAt->format('Y-m-d H:i:s');
// 通过业务方法修改状态
$user->recordLogin();
echo $user->lastLoginAt?->format('Y-m-d H:i:s'); // 输出当前时间
// 尝试非法赋值会抛出异常
// $user->name = ''; // InvalidArgumentException
// $user->email = 'abc'; // InvalidArgumentException
// $user->createdAt = new DateTimeImmutable(); // Error: private set
关键点在于,之前那些 getter 和 setter 里藏着的校验逻辑,现在全部内聚在属性定义里。你在看这个类的属性列表时,就能直接看到每个属性的校验规则和访问权限,不需要在方法列表里来回查找。
五、进阶用法:虚拟属性和计算属性
属性钩子不一定要对应一个幕后存储字段。有些属性本身是通过其他属性计算出来的,这在展示层里经常用到。
class UserProfile
{
public string $firstName {
get => $this->firstName;
set => $this->firstName = trim($value);
}
public string $lastName {
get => $this->lastName;
set => $this->lastName = trim($value);
}
// 全名:虚拟属性,没有幕后存储,实时计算
public string $fullName {
get => $this->firstName . ' ' . $this->lastName;
}
// 头像URL:依赖其他属性的计算属性
public string $avatarUrl {
get {
$hash = md5(strtolower($this->email));
return "https://gravatar.com/avatar/{$hash}?s=200";
}
}
private string $firstName;
private string $lastName;
private string $email;
public function __construct(string $firstName, string $lastName, string $email)
{
$this->firstName = $firstName;
$this->lastName = $lastName;
$this->email = $email;
}
}
// 使用
$profile = new UserProfile('张', '伟', 'zhang@example.com');
echo $profile->fullName; // "张 伟"
echo $profile->avatarUrl; // Gravatar URL
fullName 和 avatarUrl 没有自己的幕后存储,每次访问时实时计算。对于调用方来说,它和普通属性没有任何区别——都是 $obj->prop 的方式访问。这种模式在数据展示、DTO 转换、API 响应格式化时非常顺手。
六、属性钩子与构造函数的关系
这是 PHP 8.4 属性钩子里一个容易踩的坑。构造函数里给一个带钩子的属性赋值时,必须先确保幕后存储字段已经初始化,否则 set 钩子里引用 $this->prop 时会触发”未初始化属性”的错误。
正确的做法是在构造函数里先给幕后字段一个默认值,再通过属性赋值触发钩子。你也可以在属性定义时直接给默认值:
class Config
{
public string $appName {
get => $this->appName;
set(string $value) {
if (strlen($value) > 50) {
throw new InvalidArgumentException('应用名称不能超过50个字符');
}
$this->appName = $value;
}
} = 'My Application'; // 属性默认值
private string $appName;
}
这样即使构造函数里没有显式给 $appName 赋值,属性也已经有了合法的初始值。
七、属性钩子的局限
新特性虽好,但有几点目前还做不到,需要心里有数:
- 钩子里不能调用其他带钩子的属性。如果 getA 里访问了 getB,可能会触发循环引用或者初始化顺序问题。PHP 目前的实现不保证钩子之间的调用顺序。
- 属性钩子不能被单独覆盖。如果子类想复用父类属性的 get 钩子但修改 set 钩子,目前没有直接的办法,只能整体重写属性。
- 接口不能声明属性钩子。接口里仍然只能声明方法,不能声明带钩子的属性。这是一个被社区讨论很多的话题,可能在后续版本里放开。
- 有些 IDE 的自动补全还不够智能。属性钩子是全新的语法,IDE 支持在初期可能不完全,比如对钩子内部
$this->prop的类型推断有时候会失效。
这些问题不影响日常使用,在你决定在项目里全面采用属性钩子之前,在本地环境充分测试即可。
八、迁移策略:从传统实体类逐步过渡
如果你手上有一个运行中的 PHP 8.3 项目,升级到 8.4 后不需要一次性把所有类都改成属性钩子。推荐的迁移顺序是:
先改 DTO 和数据对象。那些纯粹用来承载数据的类,getter 和 setter 最多,改写收益也最大。把 getName() / setName() 替换成带钩子的 public string $name,对外接口从方法调用变成属性访问,语义更清晰。如果外部代码已经大量使用了 getter 和 setter,可以暂时保留方法作为别名,内部委托给属性钩子。
再改值对象。像 Email、PhoneNumber 这种包装类,原先的校验逻辑在构造函数里,现在可以放在属性的 set 钩子里,让校验和赋值更内聚。
实体类最后改。实体类里的状态变更往往伴随着领域事件或其他副作用,set 钩子虽然能处理校验,但不应该包含过多的业务逻辑。如果某个属性的赋值需要触发事件、更新其他聚合、或者操作外部服务,这些逻辑还是应该留在显式的业务方法里,属性的 set 钩子只负责数据校验。
九、总结
属性钩子和非对称可见性不是那种”有了就能写出完全不同的架构”的特性,它们是”删代码”的特性。它们减少的是那些你写了无数遍、每次 CR 都懒得多看一眼的 getter 和 setter。一个类少十几个方法,整个项目的可读性和维护效率会有可感知的提升。
更有价值的是,属性的访问控制语义终于和属性定义放在一起了。以前你需要在一个地方定义 private string $email,在另一个地方写 getEmail() 和 setEmail(),再在别的地方加校验逻辑。现在所有和 $email 相关的规则——能不能读、能不能写、写的时候要过什么校验——都在属性定义的那个代码块里。对后来读代码的人来说,信息密度大幅提高。
文中的所有代码都需要 PHP 8.4 及以上版本。如果你用的是 Docker 或 Laravel Herd,切换版本几分钟就能搞定。把文中的用户实体类复制进去跑一遍,感受一下没有 getter 和 setter 的类到底清爽到什么程度。

