PHP 8.4 属性钩子实战:用真实实体类讲清楚 get 与 set 的写法

2026-10-12 0 875

先看一段大部分 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,这是个挺值得为它升一次版本的理由。

PHP 8.4 属性钩子实战:用真实实体类讲清楚 get 与 set 的写法
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 php PHP 8.4 属性钩子实战:用真实实体类讲清楚 get 与 set 的写法 https://www.taomawang.com/server/php/2926.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务