PHP 8.4 的发布带来了两个令无数开发者兴奋的特性:属性钩子(Property Hooks)和不对称可见性(Asymmetric Visibility)。这两个特性彻底改变了我们定义对象属性的方式,使得过去需要借助模板方法、魔法方法甚至注释约定才能实现的 getter/setter 逻辑,现在可以直接以声明式的方式嵌入到属性定义中。本文将通过一个完整的电商数据模型重构案例,从基本语法到高级应用,手把手带你掌握如何利用这些新特性写出更安全、更清晰且更易维护的面向对象代码。
一、告别 getXxx() 和 setXxx():属性钩子的核心思想
在传统的 PHP 面向对象编程中,为了封装对象的内部状态,我们通常会为每个需要受控访问的属性编写一对 getter 和 setter 方法。这导致了大量模板代码,并且属性与方法在语义上仍是分离的——调用者必须知道应该使用 getName() 还是直接访问 $obj->name。
属性钩子允许你在属性的定义中直接声明 get 和 set 钩子,当外部代码读取或写入该属性时,对应的钩子函数会被自动调用。从调用者的角度来看,它仍然像一个普通属性,但内部却可以执行验证、转换、惰性加载等逻辑。
1.1 基础语法:get 和 set 钩子
以下是一个最简单的例子,演示如何在属性上定义 get 钩子来实现只读属性:
<?php
class Product
{
public string $name {
get {
return strtoupper($this->name);
}
}
public function __construct(string $name)
{
$this->name = $name; // 写入时不会触发钩子(未定义set)
}
}
$product = new Product('Laptop');
echo $product->name; // 输出:LAPTOP
注意,这里 get 钩子中返回 $this->name 是安全的,因为它实际上访问的是属性的原始存储值,而不会再次触发 get 钩子。钩子内部对自身属性的访问直接绕过钩子,避免了无限递归。
1.2 同时定义 get 和 set 钩子
当需要同时控制读写时,可以定义 set 钩子。set 钩子接受一个参数,表示将要写入的新值:
class Product
{
public float $price {
get {
return $this->price;
}
set($value) {
if ($value < 0) {
throw new InvalidArgumentException('价格不能为负数');
}
$this->price = round($value, 2); // 自动四舍五入保留两位小数
}
}
public function __construct(float $price)
{
$this->price = $price; // 通过set钩子写入
}
}
$product = new Product(19.995);
echo $product->price; // 输出:20.0
在这个例子中,外部代码无法设置负数的价格,而且所有传入的价格都会被自动格式化为两位小数。整个验证和转换逻辑成为属性定义的一部分,无需再创建单独的 setPrice 方法。
二、不对称可见性:读写权限的精细控制
PHP 8.4 同时引入了不对称可见性,允许为属性的“读”和“写”分别指定不同的访问修饰符。这意味着你可以让一个属性对外部公开可读,但只允许类内部或子类修改。结合属性钩子,可以实现极其精细的访问控制。
2.1 单独控制读/写可见性
语法格式为 读可见性 属性名 { 写可见性 set; },其中默认写可见性与读可见性相同,但通过 set 关键字可以单独指定:
class User
{
public string $name {
private set; // 外部只读,仅类内部可写
}
public function __construct(string $name)
{
$this->name = $name;
}
public function changeName(string $newName): void
{
// 通过方法修改,可以加入业务逻辑
if (strlen($newName) < 2) {
throw new InvalidArgumentException('名称至少2个字符');
}
$this->name = $newName;
}
}
$user = new User('Alice');
echo $user->name; // 可以公开读取
// $user->name = 'Bob'; // 错误:set 权限为 private
这一特性完美替代了过去使用 __get 和 __set 魔法方法或手写 getter 的模式,读写权限直接从属性声明中一目了然。
2.2 与属性钩子结合:惰性加载与缓存
当读可见性为 public,写可见性为 private 时,再配合 get 钩子,可以实现惰性加载和缓存。例如,一个商品需要从数据库加载详情,但只在真正访问时才查询:
class Product
{
private ?array $detailsCache = null;
public string $description {
get {
// 惰性加载描述信息
$this->loadDetails();
return $this->description;
}
private set; // 外部不可写
}
public float $rating {
get {
$this->loadDetails();
return $this->rating;
}
private set;
}
public function __construct(
public readonly int $id,
) {}
private function loadDetails(): void
{
if ($this->detailsCache === null) {
// 模拟数据库查询
$data = Database::fetchProduct($this->id);
$this->description = $data['description'];
$this->rating = $data['rating'];
$this->detailsCache = $data;
}
}
}
在这个例子中,$description 和 $rating 对外部公开可读,但无法直接写入。当它们第一次被访问时,自动触发数据库查询并缓存结果,后续访问直接返回缓存。这种模式比过去通过显式调用 load() 方法要自然得多,对象的内部优化对调用者完全透明。
三、实战案例:重构电商订单数据模型
现在,我们用一个完整的电商订单模型来展示属性钩子和不对称可见性如何大幅简化业务代码。考虑一个订单(Order)包含订单项(OrderItem),并且需要计算总金额、应用折扣、验证数量等逻辑。
3.1 订单项类:使用钩子实现自动金额计算
class OrderItem
{
public function __construct(
public readonly string $productName,
public float $unitPrice {
set {
if ($value <= 0) {
throw new InvalidArgumentException('单价必须大于0');
}
$this->unitPrice = $value;
}
},
public int $quantity {
set {
if ($value < 1) {
throw new InvalidArgumentException('数量至少为1');
}
$this->quantity = $value;
}
},
) {}
// 派生属性:通过get钩子动态计算小计
public float $subtotal {
get {
return $this->unitPrice * $this->quantity;
}
}
}
$item = new OrderItem('机械键盘', 299.0, 2);
echo $item->subtotal; // 598.0
$item->quantity = 3; // 通过set钩子自动验证,并更新subtotal
echo $item->subtotal; // 897.0
类的使用者只需像操作普通属性一样修改数量或单价,小计会自动保持同步,完全不需要显式调用计算方法。
3.2 订单类:不对称可见性保护核心数据
class Order
{
public string $status {
private set; // 外部只读,仅内部方法可变更状态
}
public float $totalAmount {
get {
// 动态计算订单总额(基于所有订单项)
return array_sum(array_map(
fn(OrderItem $item) => $item->subtotal,
$this->items
));
}
}
/** @var OrderItem[] */
private array $items = [];
public function __construct() {
$this->status = 'pending';
}
public function addItem(OrderItem $item): void
{
if ($this->status !== 'pending') {
throw new RuntimeException('只能向待处理订单添加商品');
}
$this->items[] = $item;
}
public function confirm(): void
{
if (empty($this->items)) {
throw new RuntimeException('订单没有商品,无法确认');
}
$this->status = 'confirmed';
}
public function cancel(): void
{
if ($this->status === 'shipped') {
throw new RuntimeException('已发货订单无法取消');
}
$this->status = 'cancelled';
}
}
在这个设计中:
$status公开可读,但只能通过confirm()或cancel()等业务方法修改,防止外部代码随意篡改状态。$totalAmount是只读的派生属性,通过 get 钩子实时计算,保证数据一致性。- 不对称可见性使得类内部保留对属性的完全写权限,而外部代码受到严格约束。
3.3 使用新特性后的代码对比
在传统写法中,同样的功能至少需要为每个属性编写独立的 getter/setter 方法,并且视图模板中必须使用 $order->getStatus() 而非 $order->status。新特性让领域模型的表达更加自然,同时将业务规则(如金额计算、状态流转)集中在属性定义中,减少了分散在多个方法中的验证逻辑。
四、进阶技巧:抽象属性钩子与接口约束
属性钩子不仅可以应用于具体类,还可以在抽象类和接口中声明,强制子类实现特定的读写逻辑。
4.1 接口中的属性钩子
接口可以声明带钩子的属性,定义其读写契约:
interface OrderInterface
{
public string $status {
get;
private set;
}
public float $totalAmount {
get;
}
}
class Order implements OrderInterface
{
// 必须提供与接口一致的属性定义
public string $status {
get => $this->status;
private set;
}
public float $totalAmount {
get => $this->calculateTotal();
}
// ...
}
这确保了所有实现了 OrderInterface 的类都提供一致的属性访问方式,而调用者无需关心具体实现。
4.2 抽象类中的抽象属性钩子
抽象类同样可以声明抽象属性,要求子类提供 get 或 set 实现:
abstract class ValueObject
{
abstract public string $displayValue { get; }
}
class Money extends ValueObject
{
public function __construct(private float $amount, private string $currency) {}
public string $displayValue {
get => sprintf('%.2f %s', $this->amount, $this->currency);
}
}
这种模式在领域驱动设计(DDD)中的值对象场景里极为实用,让抽象层能够约束子类提供统一的属性表达方式。
五、注意事项与最佳实践
- 避免在钩子中执行昂贵的操作:get 钩子会在每次读取属性时调用,如果钩子中有数据库查询或复杂计算,应当配合缓存策略(如上述惰性加载案例所示)。
- set 钩子中不应触发远程副作用:尽管语法上允许,但在 set 钩子中发送 HTTP 请求或修改全局状态会造成难以追踪的 bug。set 钩子应专注于验证和赋值。
- 不对称可见性与 readonly 的互斥:如果一个属性同时标注了
readonly和不对称可见性,两者会冲突,因为readonly隐含了写可见性受限,而可见性修饰符已经可以表达这一点。在 PHP 8.4 中,推荐用private set替代readonly来实现受控的不可变性。 - 反射与序列化:钩子定义的属性在反射时会显示为普通的属性,但钩子本身可通过新的反射方法访问。序列化时,钩子逻辑不会被执行,仅处理裸属性值。
六、总结
PHP 8.4 的属性钩子和不对称可见性不仅仅是语法糖,它们是 PHP 面向对象模型的一次重要升级。通过将传统的 getter/setter 方法内聚到属性定义中,代码的可读性和维护性得到了质的飞跃。不对称可见性进一步补全了访问控制的粒度,让“对外只读、对内可写”这种常见的业务需求可以直接在属性声明中实现。
在本文的电商订单案例中,我们看到了这些新特性如何自然地融入真实业务模型,消除了大量样板代码,同时将核心规则集中在定义身边。当你开始采用 PHP 8.4 时,建议优先在数据模型和值对象中尝试属性钩子,逐步淘汰陈旧的 getXxx()/setXxx() 方法,让你的代码库向更现代、更安全的风格演进。

