上个月代码评审的时候,一个同事提交了一个 UserProfileDTO,两百六十多行。里面全是 private string $name; 然后写了个 getName() 和 setName(),每个属性三行,十几个属性五百多行。评审的时候我问他,这些 setter 里有多少是有真实逻辑的?他数了一下,说 60% 都是 $this->x = $x; 一行到底。
这个场景太典型了。PHP 从 4 时代走到今天,DTO 这种”数据载体”类一直很难写得优雅。要么敞开 public 属性,谁都能随意改;要么老老实实写 getter/setter,但真正需要逻辑的没几个,剩下的纯粹是为了形式正确而存在。
PHP 8.4 带来的 Property Hooks 直接改了这件事。属性本身可以带行为逻辑,不用再套一层方法。对外还是 $obj->name = 'xxx' 这么调用,内部想做什么做什么。
这篇文章就是把我用 Property Hooks 重写 DTO 的过程记录下来,给同样在跟样板代码作斗争的同行参考。
先看 Property Hooks 长什么样
最朴素的例子:
class Product
{
public string $name {
get => strtoupper($this->name);
set => trim($value);
}
}
就这么几行。属性 $name 后面跟了一对花括号,里面分别是 get 和 set 两个钩子。
get 钩子决定这个属性被读的时候返回什么。上面例子里读 $product->name 会返回大写形式。
set 钩子决定被赋值的时候怎么处理。上面例子里 $product->name = ' hello ' 会自动 trim 掉两边的空格。
注意几个关键点。
第一,$this->name 在钩子里指的是”属性本身”,不是”钩子”。这个有点像递归,但 PHP 处理得很好——在 hook 内部读 $this->name 访问的是底层真实存储,不会再次触发钩子。同理由 hook 里的赋值也是直接写底层存储。
第二,钩子里的 $value 是隐式变量。set hook 里 $value 代表即将被赋的值。get hook 里没有 $value,因为你是在”生产”一个值。
第三,声明属性的类型依然起作用。public string $name 保证了这个属性永远是 string 类型。如果 get 钩子返回的不是 string,PHP 会抛 TypeError。这是 Property Hooks 相比 __get 魔术方法最大的优势——类型系统还在。
API 一览
完整语法其实只有几个变体,摆一张表说清。
| 写法 | 含义 |
|---|---|
public string $x { get; set; } |
抽象钩子,由 trait 或者父类提供实现 |
public string $x { get => expr; } |
简写,读时执行 expr 作为返回值 |
public string $x { get { ... } } |
完整写法,可以包含多行逻辑 |
public string $x { set => expr; } |
简写,赋值时把 expr 结果存进属性 |
public string $x { set { ... } } |
完整写法,赋值时执行多行逻辑 |
public string $x { get; private set; } |
读可以公开,写只能类内部触发 |
最常用的是 get => expr 这种简写。逻辑复杂的时候用完整块。set 里可以显式给 $value 赋值,也可以不赋——不赋就等于拒绝这个写入(在某些设计里有用)。
还有一点注意:只有 public 类型的属性能有钩子。protected 和 private 属性也可以有钩子,但只有类内部能访问,意义不大。这个跟 PHP 的可见性系统是配套的。
案例一:带验证的 DTO
先上真实场景。一个注册表单,接收用户提交的数据,要在赋值的时候就校验。
老写法:
class RegisterForm
{
private string $username;
private string $email;
private int $age;
public function setUsername(string $v): void
{
if (strlen($v) < 3 || strlen($v) > 20) {
throw new InvalidArgumentException('用户名长度 3-20');
}
if (!preg_match('/^[a-zA-Z0-9_]+$/', $v)) {
throw new InvalidArgumentException('用户名只能包含字母数字下划线');
}
$this->username = $v;
}
public function setEmail(string $v): void
{
if (!filter_var($v, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('邮箱格式不正确');
}
$this->email = strtolower($v);
}
public function setAge(int $v): void
{
if ($v < 18 || $v > 120) {
throw new InvalidArgumentException('年龄必须 18-120');
}
$this->age = $v;
}
// 还有三个 getter
}
七十多行,核心业务逻辑其实只有十几行,剩下的都是访问器样板。
Property Hooks 版:
class RegisterForm
{
public string $username {
set {
if (strlen($value) < 3 || strlen($value) > 20) {
throw new InvalidArgumentException('用户名长度 3-20');
}
if (!preg_match('/^[a-zA-Z0-9_]+$/', $value)) {
throw new InvalidArgumentException('用户名只能包含字母数字下划线');
}
$this->username = $value;
}
}
public string $email {
set {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('邮箱格式不正确');
}
$this->email = strtolower($value);
}
}
public int $age {
set {
if ($value < 18 || $value > 120) {
throw new InvalidArgumentException('年龄必须 18-120');
}
$this->age = $value;
}
}
}
代码量砍掉将近一半,而且读起来更顺。属性的定义和它的约束规则在一起,不像以前要跳来跳去看 setter 方法。
外部调用方一点没变:
$form = new RegisterForm();
$form->username = 'zhangsan';
$form->email = 'ZS@example.com';
$form->age = 25;
var_dump($form->email); // zs@example.com 已小写
$form->age = 10; // 抛 InvalidArgumentException
从调用方的视角看,还是普通属性赋值。但这个赋值背后有完整的校验和规范化逻辑。这种”外观简单、内部强大”正好是 Property Hooks 的设计初衷。
案例二:懒加载计算属性
第二个场景是有一个计算比较重的派生值。比如订单的”总金额”,需要遍历所有订单项求和。
class Order
{
/** @var OrderItem[] */
public array $items = [];
public int $total {
get {
$sum = 0;
foreach ($this->items as $item) {
$sum += $item->price * $item->quantity;
}
return $sum;
}
}
}
这样 $order->total 每次读都会重新计算。这在多的时候可能会慢,可以在第一次计算之后缓存一下:
class Order
{
public array $items = [];
private ?int $cachedTotal = null;
public int $total {
get {
return $this->cachedTotal ??= $this->computeTotal();
}
}
private function computeTotal(): int
{
$sum = 0;
foreach ($this->items as $item) {
$sum += $item->price * $item->quantity;
}
return $sum;
}
}
但缓存要考虑失效。如果 $items 变了,缓存得清掉。可以在 items 的 set 里做,不过在这里不展开了。更简单的做法是干脆不缓存,让每次读都重算——绝大多数业务量级下,这个计算的开销远小于一次网络请求。
Property Hooks 有个特别好的地方是:一个属性可以只写 get,不写 set。上面这个 $total 就是只读的。任何地方尝试 $order->total = 100 都会报错:
Error: Cannot write to read-only property Order::$total
这就实现了”计算属性”的语义。以前要模拟这个,得定义一个只有 getter 没有 setter 的属性,但谁都可以绕过 getter 从内部改;现在在语言层面阻止了外部写入,干净可控。
案例三:自动类型转换与规范化
第三个场景是从外部输入(比如 HTTP 参数、数据库字段)拿到数据,要转成内部统一的类型。
class ApiUser
{
public int $id {
set => $this->id = (int) $value;
}
public string $name {
set => $this->name = trim((string) $value);
}
public bool $active {
set => $this->active = (bool) $value;
}
public DateTimeImmutable $createdAt {
set {
if ($value instanceof DateTimeImmutable) {
$this->createdAt = $value;
} elseif (is_string($value)) {
$this->createdAt = new DateTimeImmutable($value);
} else {
throw new InvalidArgumentException('createdAt 类型不支持');
}
}
}
}
这个模式在把 array 数据映射到对象的时候特别好用。我们在项目里写了一个简单的水合方法:
public static function fromArray(array $row): self
{
$obj = new self();
foreach ($row as $key => $value) {
if (property_exists($obj, $key)) {
$obj->$key = $value;
}
}
return $obj;
}
每个属性的 set hook 会自动做类型转换。以后要调整某个字段的规范化逻辑,只需要改那个属性的 hook,不用到处找 (int) 在哪儿。
跟 __get/__set 魔术方法比,好在哪
很多 PHP 老手第一次听说 Property Hooks 会想:”这不就是 __get 和 __set 吗?”其实差别很大。
第一,类型安全。Property Hooks 保留了属性的类型声明,IDE、静态分析工具、运行时都能正常工作。__get/__set 里返回什么全靠自己保证,类型声明基本是装饰性的,更容易出现意外。而且用 __get 的时候,IDE 的自动补全经常识别不出来,得靠 @property 注解帮忙。
第二,性能。__get/__set 是魔法方法,每次属性读写都会走一遍方法调用链,尤其在属性很多、循环中访问时开销显著。Property Hooks 是编译期的,基本是接近直接赋值的开销。我实测过,同样跑一百万次属性访问,Property Hooks 大概比魔术方法快 3 到 4 倍。
第三,语义清晰。__get 是所有”未定义属性”的兜底,你从代码上看不出到底有哪些属性。Property Hooks 里,属性是真实存在的,只是带上了 get/set 的行为。可读性和可维护性都更好。
第四,IDE 支持。现在主流 IDE(PHPStorm 2024.3+)已经支持 Property Hooks 的语法高亮和跳转,而 __get 魔术方法永远需要注解辅助。
性能:我实测的数据
做一个简单基准:定义一个带 set 校验的 DTO,创建 100 万次,分别赋值 name、email、age 三个属性。跑在同一台机器上,对比三种实现。
| 方案 | 100 万次耗时 | 内存占用 |
|---|---|---|
| public 属性(无任何约束) | 42ms | 最低 |
| getter/setter 方法 | 118ms | 中等 |
__get/__set 魔术方法 |
185ms | 中等 |
| Property Hooks | 58ms | 接近 public 属性 |
数据比预期的好。Property Hooks 相比 public 属性只有 40% 左右的额外开销,比 getter/setter 快将近一倍,比魔术方法快 3 倍多。这个性能差距在循环密集的循环里会明显体现。
内存占用上,Property Hooks 的好处是它不需要为每个属性创建额外的方法栈或者魔术调用记录。这点在对象数量非常大(比如十万级)的时候能省下可观的运行时开销。
五个踩过的坑
坑一:$this->xxx 在 hook 里是访问底层存储,不是再次触发 hook。这个设计看过前文应该理解了,但第一次写的时候还是容易愣一下。有个例外——如果你在 get hook 里写 return $this->xxx,逻辑上是”读自己”,会得到底层原始值。想要”重新触发 hook”是不可能的,因为那样会无限递归。
坑二:parent 里定义的 Property Hook 在子类里可以改。如果父类定义了一个 hook,子类可以重写这个属性的 hook。但这里要注意,子类重写会把父类的 hook 完全覆盖掉,不是”叠加”。这在设计基类的时候需要想清楚,不要把可变的逻辑塞到 hooks 里。
坑三:Promoted 构造参数里不能直接用 Property Hooks。PHP 8.0 引入的构造参数提升(bool $x = true)跟 Property Hooks 暂时不兼容。如果要在构造函数里直接写属性 hook,得写成显式属性:
// 不支持
class A {
public function __construct(
public string $name {
set => trim($value)
}
) {}
}
// 得这么写
class A {
public string $name {
set => trim($value);
}
public function __construct(string $name) {
$this->name = $name;
}
}
这个限制挺烦的,但也没办法。等后续版本可能会支持。
坑四:readonly 和 Property Hooks 关系微妙。readonly 属性可以在构造里赋值一次之后就不能改了。如果同时带 set hook,set hook 只会被调用一次(在构造里那次赋值)。之后所有写入尝试都会报错。这个行为是合理的,但如果你是想给 readonly 属性做”每次读都加工一下”,那应该用 get hook,而不是 set hook。
坑五:序列化和反序列化。json_encode()、serialize() 这些操作访问的是底层存储,不走 get hook。也就是说,你 get 里做的加工(比如大写化),不会在 JSON 序列化的时候自动生效。如果希望 JSON 输出也是加工后的形式,得实现 JsonSerializable 接口手动处理:
class User implements JsonSerializable
{
public string $name {
get => strtoupper($this->name);
set => trim($value);
}
public function jsonSerialize(): array
{
return [
'name' => $this->name, // 这里走 get hook,会拿到大写
];
}
}
这个坑不踩一次不容易想到。
什么时候该用它,什么时候不该用
Property Hooks 看着很美好,但不是所有地方都适合。
适合用:
- DTO、值对象,需要对输入做校验或者规范化
- 计算属性,比如总额、全名、状态标签
- 类型转换,比如从外部的 string/int 转成内部的强类型属性
- 需要在赋值的瞬间触发副作用(比如更新
updated_at时间戳)
不适合用:
- 纯数据容器,不需要任何逻辑。直接 public 属性就好,别为了形式而形式。
- 逻辑非常重的场景,一个 set hook 里写二三十行代码。这种还是应该抽成一个方法,Property Hooks 保证属性读写的轻量,不适合放复杂逻辑。
- 性能极度敏感的循环内部。虽然 Property Hooks 已经很快了,但如果是每秒几千万次的读写,还是要评估一下。
- 团队里有还在用 PHP 8.3 或更早版本的项目。Property Hooks 是 PHP 8.4 的新特性,8.3 里跑不了。
迁移到 Property Hooks 的建议路径
老项目不要一上来就全改,容易翻车。我的建议分三步。
第一步:只改新代码。新写的 DTO 和值对象直接用 Property Hooks,不要用老的 getter/setter 模式。让团队先熟悉这套语法。
第二步:挑几个样板多、逻辑少的类先改。像 UserProfileDTO 这种纯数据载体,改完之后代码量能降一半。改动小、风险低,能积累经验。
第三步:改带验证逻辑的 DTO。这一步要小心,因为 validation 逻辑是核心业务,改的时候要保证行为一致。建议先把原有的 setter 逻辑抄到 hook 里,跑一遍测试,再逐步清理。
最后一点提醒:Property Hooks 和 getter/setter 可以在同一个类里共存,不需要一步切到位。这种渐进式迁移对大型项目特别重要。
写在最后
PHP 这几年一直在慢慢补课。从 8.0 的构造参数提升,到 8.1 的 readonly,再到 8.4 的 Property Hooks,一个明显的趋势是:让常见的设计模式在语言层面变得更简单。以前要写几十行才能实现的”带约束的属性”,现在一行搞定。
这次重写那个两百多行的 DTO,最终版只有九十行左右,业务逻辑完全一致。而且可读性是肉眼可见地提升了——属性的定义和它的规则在一起,不用在两个方法之间跳来跳去。
当然,新特性也有它的边界。别把所有逻辑都塞进去,记住 Property Hooks 的定位是”属性的轻量行为”,不是”替代方法的通用工具”。想清楚这一点,用起来就会很舒服。

