---
title: PHP 8.4+ 属性钩子与只读类：把校验收进模型
url: https://vabora.dev/posts/php85-property-hooks
author: Vabora
published: 2026-08-06 09:30:00
updated: 2026-09-27 10:33:42
category: 技术分享
tags: 架构设计, PHP 8
canonical: https://vabora.dev/posts/php85-property-hooks
license: 转载与引用请注明出处并保留原文链接
---

# PHP 8.4+ 属性钩子与只读类：把校验收进模型

> 属性钩子（Property Hooks）让"取值/赋值时做点事"不再依赖魔术方法或 setter 样板。这篇讲它解决什么问题、与 __get/__set 的区别，以及只读类与 clone 的配合。
## 背景

业务模型里最常见的样板代码是访问器：

```php
final class Money
{
    private int $amount = 0;

    public function getAmount(): int { return $this->amount; }
    public function setAmount(int $amount): void
    {
        if ($amount < 0) {
            throw new InvalidArgumentException('金额不能为负');
        }
        $this->amount = $amount;
    }
}
```

一个字段配一对方法，模板代码比业务逻辑还长。属性钩子把这两件事合回属性本身。

## 做法

### 1. 校验直接写在属性上

```php
final class Money
{
    public int $amount = 0 {
        set (int $value) {
            if ($value < 0) {
                throw new InvalidArgumentException('金额不能为负');
            }
            $this->amount = $value;
        }
    }
}
```

调用侧仍然是 `$money->amount = 100;`，但非法值进不来。**好处不只是少写代码**：校验跟着字段走，新增字段时不会忘了加校验。

### 2. 派生字段惰性计算

```php
final class Post
{
    public string $content_md = '';

    public string $excerpt {
        get => mb_substr(trim(strip_tags($this->content_md)), 0, 80);
    }
}
```

`$post->excerpt` 是只读派生值——不需要在每次保存时同步冗余字段，也不会有"改了正文忘了改摘要"的不一致。

### 3. 与只读类配合

`readonly` 属性不能有 `set` 钩子（只能在构造器里赋值），但可以有 `get` 钩子做格式化。实践组合是：

- **值对象**（Money、Email、Slug）用 `readonly` + 构造期校验；
- **可编辑模型**（Post、Setting）用属性钩子做赋值校验。

### 4. 与 `__get`/`__set` 的本质区别

魔术方法是在**访问不存在的属性**时兜底，它破坏了静态分析，IDE 也看不懂；属性钩子是真实属性，类型、补全、`readonly`、序列化都照常工作。这是它值得迁移的核心理由。

## 结果

- 模型层样板代码明显减少，校验从"分散在各处 setter"变成"贴着字段"；
- 静态分析（IDE 与 `php -l`）仍能理解属性类型，重构改字段名不会漏。

## 取舍

- **不把复杂逻辑塞进钩子**：钩子里只做校验与轻量派生。需要查库、发事件的逻辑仍然放在服务层——钩子会隐式触发，塞重活会让调用链难以追踪。
- **`get` 钩子里不要改状态**：读操作有副作用是最难排查的一类 bug，惰性计算要保证幂等。
- **对外 API 仍用显式方法**：跨模块的契约（如 `PostService::save()`）保持方法调用，属性钩子只在模型内部使用——这样以后换实现不必改调用方。
