先看一段大部分 PHP 项目里都能找到的代码:
class User
{
private string $email;
private ?string $phone = null;
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 getPhone(): ?string
{
return $this->phone;
}
public function setPhone(?string $phone): void
{
if ($phone === null || $phone === '') {
$this->phone = null;
return;
}
$digits = preg_replace('/D/', '', $phone);
if (strlen($digits) !== 11) {
throw new InvalidArgumentException("手机号不合法");
}
$this->phone = $digits;
}
}
每个属性都要配一对方法,验证逻辑藏在 setXxx 里,调用方得记得用方法而不是直接赋值。跑是能跑,但这套模式一直被当成理所当然,直到 PHP 8.4 把属性钩子(Property Hooks)做了出来。
属性钩子解决的问题很直接:把 setter 里的验证逻辑,挂到属性本身上。从此 $user->email = 'hello@example.com' 这一行赋值,就会自动跑完所有校验和归一化。调用方不用再关心”到底该用方法还是属性”。
这篇文章拿上面那个类开刀,把每个属性都改成钩子写法,顺便把踩到的几个坑摆出来。
版本这块没得商量
属性钩子是 8.4 的语法特性,8.3 及以下直接语法报错。先确认:
php -v
# PHP 8.4.x (cli)
没升级的话用 Docker 起一个:
docker run --rm -v "$PWD":/app -w /app php:8.4-cli php test.php
不需要装任何扩展,钩子是语言层面的东西。
最小示例先看一眼
<?php
class Product
{
public string $name {
set (string $value) {
$trimmed = trim($value);
if ($trimmed === '') {
throw new InvalidArgumentException("名称不能为空");
}
$this->name = $trimmed;
}
}
}
$p = new Product();
$p->name = " 键盘 ";
echo $p->name; // "键盘"
几个结构上的点说一下:
- 钩子写在属性声明后面的大括号里,不是写在类里另起一块
set关键字后面括号里的$value是调用方传进来的原始值- 钩子里对
$this->name赋值,写的是底层存储,不会再次触发钩子、不会无限递归 - 读取
$p->name时因为没有get钩子,读的也是底层存储
光 set 没 get,读写都正常。但一旦定义了 get,这个属性就变成了”虚拟只读属性”,外部赋值会直接抛 Error。下一节会用到这个特性。
逐个属性改造
目标是让下面这两种调用都成立:
$user = new User();
$user->email = ' HELLO@Example.COM ';
echo $user->email; // hello@example.com
$user->phone = '138-0013-8000';
echo $user->phone; // 13800138000
$user->email = 'not-an-email'; // 抛异常
email:带验证和小写归一
public string $email {
set (string $value) {
$normalized = strtolower(trim($value));
if (!filter_var($normalized, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("邮箱格式不对:{$value}");
}
$this->email = $normalized;
}
}
trim 和 strtolower 都放到验证之前,这样 ' HELLO@Example.COM ' 也能通过。如果先验证再归一,带空格的输入会被 filter_var 拒掉,用户体验很糟。
phone:允许 null 的分支
public ?string $phone = null {
set (?string $value) {
if ($value === null || trim($value) === '') {
$this->phone = null;
return;
}
$digits = preg_replace('/D/', '', $value);
if (strlen($digits) !== 11) {
throw new InvalidArgumentException("手机号不合法:{$value}");
}
$this->phone = $digits;
}
}
这里有两个细节。第一,$value 的类型声明是 ?string,意味着传 null 是合法的;传 12345 会直接触发 TypeError。第二,空串被当作 null 处理,因为表单提交里空字符串是最常见的”用户没填”表示。
displayName:只读虚拟属性
有些属性根本不存数据,读的时候现算。传统写法会写个 getDisplayName() 方法,属性钩子可以直接做成属性:
public string $displayName {
get => trim("{$this->firstName} {$this->lastName}");
}
注意只写了 get,没有 set。这时候 $user->displayName = 'x' 会抛 Error——它自动变成只读属性。这个行为很符合直觉,调用方从代码提示里就能看到这个属性只能读。
还有个隐含的好处:displayName 不占用任何内存。它不在 var_dump 的属性列表里,也不会被序列化进去,纯粹是一个读取时触发的表达式。
firstName:验证非空
public string $firstName {
set (string $value) {
$trimmed = trim($value);
if ($trimmed === '') {
throw new InvalidArgumentException("名字不能为空");
}
$this->firstName = $trimmed;
}
}
同样的模式就不重复了。
完整改写后的类
<?php
declare(strict_types=1);
final class User
{
public string $email {
set (string $value) {
$normalized = strtolower(trim($value));
if (!filter_var($normalized, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("邮箱格式不对:{$value}");
}
$this->email = $normalized;
}
}
public ?string $phone = null {
set (?string $value) {
if ($value === null || trim($value) === '') {
$this->phone = null;
return;
}
$digits = preg_replace('/D/', '', $value);
if (strlen($digits) !== 11) {
throw new InvalidArgumentException("手机号不合法:{$value}");
}
$this->phone = $digits;
}
}
public string $firstName {
set (string $value) {
$trimmed = trim($value);
if ($trimmed === '') {
throw new InvalidArgumentException("名字不能为空");
}
$this->firstName = $trimmed;
}
}
public string $lastName = '' {
set (string $value) {
$this->lastName = trim($value);
}
}
public string $displayName {
get => trim("{$this->firstName} {$this->lastName}");
}
}
整个类从一百多行降到五十行不到,而且所有验证逻辑都贴在对应的属性旁边。以后想改邮箱的验证规则,只需要看 $email 那一段,不用在 getEmail 和 setEmail 之间来回跳。
跑一下试试:
<?php
$user = new User();
$user->email = ' HELLO@Example.COM ';
$user->phone = '138-0013-8000';
$user->firstName = '三 ';
$user->lastName = ' 张';
var_dump($user->email); // string(17) "hello@example.com"
var_dump($user->phone); // string(11) "13800138000"
var_dump($user->displayName); // string(4) "三 张"
try {
$user->email = 'not-an-email';
} catch (InvalidArgumentException $e) {
echo $e->getMessage(); // 邮箱格式不对:not-an-email
}
两个真实踩到的坑
引用传递彻底不兼容
以前写 $user->tags[] = 'vip' 这种”直接往数组里塞一项”的写法,只要 tags 属性有 get 钩子就直接报错。因为 get 钩子返回的是一个临时值,不是可以取引用的存储位置,PHP 拒绝这种操作。
public array $tags = [] {
get => array_values($this->tags);
}
$user->tags[] = 'vip';
// Error: Cannot indirectly modify property User::$tags
绕过办法是老老实实读出来改完再写回去:
$tags = $user->tags;
$tags[] = 'vip';
$user->tags = $tags;
这确实变啰嗦了,但如果 tags 的 get 钩子里做了去重、排序这类归一化处理,那这种”读改写”反而是必要的——增量修改会绕过归一化逻辑。属于安全性和方便性之间正常的取舍。
JSON 序列化会看到底层存储
json_encode($user) 走的是属性底层存储,不走 get 钩子。也就是说,displayName 这种虚拟属性不会出现在 JSON 里,而 firstName、lastName 会原样出现。
如果接口契约里需要 displayName,得自己实现 JsonSerializable:
final class User implements JsonSerializable
{
// ... 属性钩子照旧
public function jsonSerialize(): array
{
return [
'email' => $this->email,
'phone' => $this->phone,
'displayName' => $this->displayName,
];
}
}
显式列一遍所有对外字段反而是好事,接口里不小心泄露内部属性的风险也一并降下来了。
不要为钩子而钩子
属性钩子好用,但不是所有场景都适合换。列几个我实际判断为”不该换”的情况。
纯透传的 get/set
如果某个属性只是简单读、简单写,没有任何处理:
public string $nickname {
set (string $value) { $this->nickname = $value; }
}
这种还不如直接 public string $nickname; 干净。钩子不是装饰,是为了挂逻辑。没有逻辑就写普通属性。
需要多个参数的 setter
有时候一个 setter 需要额外参数:
public function setPassword(string $password, bool $notify = true): void
{
// ...
}
属性钩子的 set 只接收一个整值,没法再加参数。这种情况老老实实保留方法。
会做重活的 getter
如果某个 getter 会去查数据库、调接口、算一大堆东西,把它做成属性读取反而更糟。属性访问在阅读代码时默认是”廉价操作”,一个耗时的属性访问会让人误判代码性能。
// 不推荐
public array $activeOrders {
get => $this->orderRepository->findActiveByUser($this);
}
// 推荐保留方法
public function fetchActiveOrders(): array
{
return $this->orderRepository->findActiveByUser($this);
}
属性钩子还是用来处理”轻量、同步、无副作用”的逻辑更合适。
需要和 __get / __set 配合的时候
如果类里已经有 __get 和 __set 魔术方法,再加属性钩子容易出意外。具体表现和版本的副版本相关,不建议两块逻辑同时存在。要么全部走钩子,要么全部走魔术方法,别混。
收个尾
属性钩子最大的价值不是让代码变短,是让”这个属性的有效范围”这件事集中在一个地方。
以前 private string $email 加 setEmail() 的组合,验证逻辑和存储字段是分开的两个声明。有人忘了用 setter 直接改私有属性(通过反射或同类的其他方法),验证就被跳过了。现在验证绑在 $email 这个名字上,改成什么样都会走一遍。
配合 readonly、final class、构造器提升这些现代语法,PHP 可以写出相当收敛的领域模型。如果你手上的项目还没升到 8.4,这是个挺值得为它升一次版本的理由。

