维护过老项目的朋友应该都见过这种东西:一个数据表对应一个实体类,字段十个,getter 加 setter 二十个方法。每个方法里面就一行,return $this->name; 或者 $this->name = $value;,写起来没难度,抄起来手酸。IDE 能自动生成,但那二十个方法实打实躺在文件里,翻代码的时候要认真跳过,不然一屏看不见有用的东西。
更麻烦的是加校验。比如 price 字段必须大于零,你得在 setter 里塞几行判断;updatedAt 需要在每次设置时自动改一下 updatedBy——这种跨字段联动写进 setter 之后,职责边界就开始模糊。到底哪些字段有验证、哪些没有,得翻遍所有 setter 才知道。
PHP 8.4 的属性钩子(Property Hooks)就是为了这事来的。它允许你把读写逻辑直接写在属性定义旁边,不用再另外开方法。这篇文章把语法、常见模式和实际坑都过一遍。
一、先看一段旧代码
class Product
{
private string $name;
private int $priceInCents;
public function getName(): string
{
return $this->name;
}
public function setName(string $name): void
{
$name = trim($name);
if ($name === '') {
throw new InvalidArgumentException('商品名不能为空');
}
$this->name = $name;
}
public function getPriceInCents(): int
{
return $this->priceInCents;
}
public function setPriceInCents(int $cents): void
{
if ($cents < 0) {
throw new InvalidArgumentException('价格不能为负');
}
$this->priceInCents = $cents;
}
public function getPriceYuan(): string
{
return number_format($this->priceInCents / 100, 2);
}
}
三十多行代码,实际做的事情就三件:读、写、格式化。属性钩子能把这三件事压缩成一段:
class Product
{
public string $name {
get => $this->name;
set (string $value) {
$value = trim($value);
if ($value === '') {
throw new InvalidArgumentException('商品名不能为空');
}
$this->name = $value;
}
}
public int $priceInCents = 0 {
set (int $value) {
if ($value < 0) {
throw new InvalidArgumentException('价格不能为负');
}
$this->priceInCents = $value;
}
}
public string $priceYuan {
get => number_format($this->priceInCents / 100, 2);
}
}
功能完全一样,代码短了一半多。而且读写逻辑贴着属性本身,看一个字段的时候不用上下翻。
二、语法要点
整体结构是:
public 类型 属性名 {
get => 表达式;
set (参数类型 $value) { 语句块 }
}
几个规则需要说清楚。
get 可以直接是表达式,也可以是块
// 表达式形式
public string $fullName {
get => $this->first . ' ' . $this->last;
}
// 语句块形式
public string $fullName {
get {
$parts = array_filter([$this->first, $this->last]);
return implode(' ', $parts);
}
}
表达式形式更简洁,但只能写一条。return 关键字省略。块形式需要显式 return。
set 也可以有名参数
public string $email {
set (string $raw) {
$cleaned = strtolower(trim($raw));
if (!filter_var($cleaned, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('邮箱格式不对');
}
$this->email = $cleaned;
}
}
参数名可以随便写,不一定要叫 $value。上面这个例子里 $raw 更能表达”传进来还没处理的原始值”。
参数类型要和属性类型一致
如果属性声明是 public string $name,那 set 的参数也必须是 string。写错了会在编译期报错。这一条和 PHP 一贯的”类型声明要一致”是同一个原则。
只写 get 就是只读属性
class Circle
{
public float $radius;
public float $area {
get => M_PI * $this->radius ** 2;
}
}
$c = new Circle();
$c->radius = 5.0;
echo $c->area; // 78.539816339745
$c->area = 90; // Error: Cannot set readonly property
只定义 get 的属性不能赋值,尝试写会抛错误。这就是虚属性(virtual property)——它不占用实际内存,每次读取的时候动态算出来。
get 和 set 可以只定义一个
反过来也一样,只写 set 不写 get 的用法比较少见,但语法上允许。这时读这个属性会得到 null(前提是属性没被赋过值)或者触发未初始化错误。看起来有点怪,但某些”只写不读”的场景(比如密码)是有意义的。
class User
{
public string $password {
set (string $value) {
$this->password = password_hash($value, PASSWORD_DEFAULT);
}
get => throw new LogicException('密码字段只写不读');
}
}
这样既能安全地存密码,又能在别人尝试读出来的时候给出明确错误。
三、不对称可见性:一个新的武器
属性钩子还有一个配套特性:asymmetric visibility(不对称可见性)。它允许 get 和 set 用不同的可见性级别。
class Order
{
// 外部可以读,但只有本类内部能改
public private(set) string $status = 'pending';
}
读法:public 是读的可见性,private(set) 是写的可见性。
以前表达同样的语义,要么写个 public getter + private 属性(多一个方法),要么写 public 属性 + 私有 setter,要么靠魔术方法兜底。现在一行就够。
这个特性单独拿出来都值得写一篇文章,和属性钩子叠加起来,能表达相当丰富的模型约束:
class Order
{
public private(set) string $status = 'pending' {
set (string $value) {
if (!in_array($value, ['pending', 'paid', 'shipped'], true)) {
throw new InvalidArgumentException("非法状态:{$value}");
}
$this->status = $value;
}
}
public function markPaid(): void
{
$this->status = 'paid'; // 类内部可以改
}
}
$order = new Order();
echo $order->status; // 'pending',可以读
$order->status = 'paid'; // Error:外部不能写
$order->markPaid(); // OK:走内部方法
这段代码表达的意思很清楚:状态对世界公开、对世界只读,但内部可以通过有自己的业务方法修改,并且修改的时候会走一遍合法性校验。整个约束就在这一个属性定义里说完了。
四、案例一:带货币处理的订单金额
来写一个稍微完整点的东西。金额是电商系统里最容易出问题的地方之一,涉及精度、货币单位、格式化、跨币种转换。
class Money
{
public function __construct(
public readonly int $cents,
public readonly string $currency = 'CNY',
) {}
public static function fromYuan(float $yuan, string $currency = 'CNY'): self
{
return new self((int) round($yuan * 100), $currency);
}
public function toYuan(): float
{
return $this->cents / 100;
}
public function __toString(): string
{
return number_format($this->toYuan(), 2) . ' ' . $this->currency;
}
}
class Order
{
public private(set) Money $amount {
set (Money|int|float $value) {
$this->amount = match (true) {
$value instanceof Money => $value,
is_int($value) => new Money($value),
is_float($value) => Money::fromYuan($value),
default => throw new InvalidArgumentException('金额类型不支持'),
};
}
get {
if (!isset($this->amount)) {
throw new LogicException('订单金额还没设置');
}
return $this->amount;
}
}
public float $amountYuan {
get => $this->amount->toYuan();
}
public string $displayAmount {
get => (string) $this->amount;
}
}
用起来是这样:
$order = new Order();
$order->amount = 199.9; // 传 float,自动转成 Money 对象
echo $order->displayAmount; // 199.90 CNY
echo $order->amountYuan; // 199.9
这个例子里有意思的地方是 set 钩子的参数类型。它接受三种类型(Money、int、float),但实际存储的永远是 Money 对象。这种转换逻辑以前得写在 setter 里,调用方还要知道到底该传什么;现在从使用者的角度看,怎么传都行。
还有一处细节值得注意:amount 的 get 钩子里有未初始化检查。因为属性声明时没有默认值,在没赋过值的情况下读取,会抛一个明确的 LogicException,比默认返回 null 要安全很多。
五、案例二:用户资料字段联动
再来看一个跨字段联动的例子。用户设置昵称之后,系统需要自动生成一段英文 slug,用来做 URL 友好展示。
class UserProfile
{
public string $displayName {
set (string $value) {
$value = trim($value);
if (mb_strlen($value) > 30) {
throw new InvalidArgumentException('昵称不能超过 30 个字');
}
$this->displayName = $value;
$this->slug = $this->buildSlug($value);
}
get => $this->displayName;
}
public private(set) string $slug = '';
private function buildSlug(string $name): string
{
$slug = strtolower($name);
$slug = preg_replace('/[^a-z0-9p{Han}]+/u', '-', $slug);
return trim($slug, '-');
}
}
$profile = new UserProfile();
$profile->displayName = '张三 三';
echo $profile->displayName; // 张三 三
echo $profile->slug; // 张三-三
要注意的地方是:slug 用 public private(set) 声明,外部只能读,不能写。它的更新完全由 displayName 的 set 钩子驱动。这种”派生字段”用钩子表达特别自然,比在 setter 里手动维护要清爽。
当然这个 slug 例子里中文字符被保留了,实际项目中可能想全部转拼音,那就需要引入额外的库。这里为了说明钩子机制,简单处理一下即可。
六、虚属性、条件和延迟求值
虚属性是属性钩子里一个特别实用的能力。它不占内存,每次读取时算一遍。开销大的表达式配合 memo 会有性能优势。
class Report
{
public function __construct(
private array $rawData,
) {}
public array $summary {
get {
// 每次读都会重算 —— 如果数据大就很浪费
return array_reduce(
$this->rawData,
fn($acc, $row) => [
'count' => $acc['count'] + 1,
'sum' => $acc['sum'] + $row['amount'],
],
['count' => 0, 'sum' => 0],
);
}
}
}
如果 summary 会被多次读取,每次重算显然不划算。可以加一个私有属性做缓存:
class Report
{
private ?array $summaryCache = null;
public function __construct(
private array $rawData,
) {}
public array $summary {
get => $this->summaryCache ??= array_reduce(
$this->rawData,
fn($acc, $row) => [
'count' => $acc['count'] + 1,
'sum' => $acc['sum'] + $row['amount'],
],
['count' => 0, 'sum' => 0],
);
}
}
??= 是 PHP 7.4 引入的 null 合并赋值运算符,写法简洁。但有两个点要注意。
第一,缓存字段在任何会改 rawData 的地方都要重置。如果 rawData 是 private 并且只能在构造时赋值,那没问题;如果可以被修改,就得在修改处一并清空缓存。
第二,属性钩子本身不提供”被动失效”机制。这一点不像 Vue 的 computed,不会自动追踪依赖。缓存失效得手动管理。
条件逻辑用 in_array 判断
class PagedResult
{
public bool $hasNext {
get => $this->page * $this->perPage < $this->total;
}
public bool $hasPrev {
get => $this->page > 1;
}
public array $pageNumbers {
get {
$start = max(1, $this->page - 2);
$end = min($this->lastPage, $start + 4);
return range($start, $end);
}
}
}
分页对象里的这些派生状态,用虚属性表达比用方法要自然得多。使用方写 $result->hasNext 而不是 $result->hasNext(),读起来更像在读数据,而不是在调用方法。
七、六个实际踩过的坑
1. 不要在钩子里递归访问自己
// 死循环
public string $name {
get => $this->name;
}
这段代码是无限递归。$this->name 又会触发 get,又读到 get,又读……直到栈溢出。
正确写法有两种。第一种是让 PHP 自动处理——如果钩子里访问的属性本身有存储,直接用 $this->name 是不会有递归的,引擎会区分”钩子调用”和”实际存储访问”。第二种是显式把存储字段区分出来:
private string $nameValue;
public string $name {
get => $this->nameValue;
set (string $value) {
$this->nameValue = trim($value);
}
}
这种写法麻烦但直观,不容易出问题,尤其是在跨版本迁移的时候更保险。
需要提醒的是:更复杂的情况(比如虚属性里访问另一个虚属性、而那个又反过来访问自己)仍然可能栈溢出。写的时候注意一下依赖方向,最好保持单方向的引用关系。
2. parent 和 self 的类型检查不会自动适配
class Base
{
public string $value {
get => $this->value;
set (string $value) { $this->value = $value; }
}
}
class Child extends Base
{
// 错误:重写钩子时参数类型要一致
public string $value {
set (string|int $value) { ... }
}
}
子类重写父类的属性钩子时,参数类型和返回类型必须兼容。PHP 的协变和逆变规则在这里也适用。看似简单,但混着继承一起用的时候,很容易在运行时才发现问题。
3. readonly 和属性钩子不能同时用
// 语法错误
public readonly string $name {
get => $this->name;
}
属性钩子本身就意味着属性可以被 hook 修改,和 readonly 直接冲突。如果确实需要”只在构造时赋值、之后只读”,用 public private(set) 加一个只在构造函数里赋值的模式。
4. 不支持引用传递
属性钩子不能返回引用,也不能在 set 参数里接引用。这样设计是为了避免钩子里做副作用写到外部状态上,属于刻意限制。如果原来有类似 $obj->items[] = $x 这种写法,需要改成显式方法调用。
// 会报错
$order->items[] = $item;
// 改成
$order->addItem($item);
5. 序列化行为
json_encode($product) 的时候,虚属性(只定义了 get、没有实际存储的属性)会不会被序列化出来?答案是不会。JSON 序列化只看属性实际是否存在,虚属性没有对应的存储槽,因此不会被包含进去。
如果希望虚属性也出现在 JSON 里,需要实现 JsonSerializable,在 jsonSerialize() 里手动拼出想要的数组结构。这其实是个好事——默认行为更保守,不会因为一个虚属性就改变 API 响应结构。
反序列化时也要注意:如果属性声明的类型和 JSON 中的类型不匹配,会触发 set 钩子的类型检查。如果业务上需要宽容地接受更多输入类型,就得在钩子里做转换。
6. 属性钩子和魔术方法不要混用
如果类里同时定义了 __get/__set 和属性钩子,行为可能会让人困惑。基本规则是:属性钩子优先,魔术方法只在属性未定义或不可访问时才被调用。
结论很简单:不要混用。迁移到属性钩子之后,把对应的 __get/__set 都删掉,避免留下两个互相不知道对方存在的机制。
八、一个完整的 DTO 示例
把这些要点综合起来,写一个实际项目中可以直接用的 DTO:
final class CreateUserInput
{
public string $username {
set (string $value) {
$value = trim($value);
if (!preg_match('/^[a-zA-Z0-9_]{3,20}$/', $value)) {
throw new InvalidArgumentException('用户名需为 3-20 位字母、数字或下划线');
}
$this->username = $value;
}
get => $this->username;
}
public string $email {
set (string $value) {
$value = strtolower(trim($value));
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('邮箱格式不正确');
}
$this->email = $value;
}
get => $this->email;
}
public int $age {
set (int $value) {
if ($value < 0 || $value > 150) {
throw new InvalidArgumentException('年龄必须在 0-150 之间');
}
$this->age = $value;
}
get => $this->age;
}
public string $displayName {
get => $this->displayName ??= $this->username;
}
}
用途很清晰:在控制器接收请求参数之后,构造这个对象时就完成了全部校验,往下传给 Service 层的永远是一个合法的数据结构。不用在每次进入业务流程之前都检查一遍字段。
try {
$input = new CreateUserInput();
$input->username = $request->post('username');
$input->email = $request->post('email');
$input->age = (int) $request->post('age');
$userService->create($input);
} catch (InvalidArgumentException $e) {
return json(['error' => $e->getMessage()], 400);
}
这段代码有个明显的好处:字段的规则和字段本身绑在一起。以后要改用户名的规则,去 username 属性那里改就行,不用全项目搜”哪个地方在验证用户名”。
九、什么时候该用,什么时候别用
属性钩子是个好东西,但不是所有场景都值得上。
适合的场景:DTO 和值对象;需要在读写时做校验的字段;派生字段(如全名、格式化金额、布尔状态);配置类对象;任何字段读写逻辑比较独立的地方。
不适合的场景:逻辑很重、涉及多个外部依赖的属性——这种更适合抽成独立方法或者服务;需要延迟加载大对象的属性——钩子里做太多事,读属性的时候很容易触发意外 IO;已经被 ORM 魔术方法管理的实体类——和钩子混用容易出问题,具体看 ORM 的版本支持程度。
还有一件事需要提醒:如果你维护的类库需要兼容 PHP 8.3 及更早版本,属性钩子暂时不能用。这种情况下可以先在项目内部约定使用,等所有环境升级完之后再统一迁移公共代码。
十、写在最后
属性钩子看起来只是语法糖——把 getter 和 setter 换个地方写。但用了一段时间之后,我的感受是它改变了写代码时的思考方式。
以前写实体类的时候,习惯先把字段列一遍,然后去写对应的方法,字段的约束散落在方法体里。现在写的时候,是一个字段一个字段地过:这个字段的类型是什么,读起来要做什么处理,写进去之前要验证什么。整个类的结构从”方法集合”变成了”字段以及它们的规则”。
这种视角的变化,比语法上的简化更有价值。在调试的时候也一样——看到某个字段的值不对,直接去看它的钩子就行,不用先找 setter 再进去。
如果你的项目正在升级到 PHP 8.4,建议先挑一个简单的 DTO 试一下。看到 public private(set) string $status 这一行是怎么替代八个方法的,就会知道这个特性值不值得用起来。

