用 PHP 写数据对象最烦的是什么?十个属性有八个要getter,还有两三个需要修改状态。你写了public属性又怕外部随意改动,于是退回到private加getter。PHP 8.4 出了一个新特性叫“不对称可见性”,终于解决了这个尴尬:属性可以公开读取,但只允许在类内部写入。今天拿一个订单实体的例子来整明白。
以前是怎么做的
一个订单对象,创建后订单号、用户ID、金额不应该被修改,但状态需要可以在内部变。传统写法可能是这样:
class Order {
private string $orderNo;
private int $userId;
private float $amount;
private string $status;
public function __construct(string $orderNo, int $userId, float $amount) {
$this->orderNo = $orderNo;
$this->userId = $userId;
$this->amount = $amount;
$this->status = 'pending';
}
public function getOrderNo(): string { return $this->orderNo; }
public function getUserId(): int { return $this->userId; }
public function getAmount(): float { return $this->amount; }
public function getStatus(): string { return $this->status; }
public function markPaid() {
$this->status = 'paid';
}
}
代码不算复杂,但一旦字段变多,那一排getter看了就让人烦躁。更难受的是,如果需要很多只读字段,你只能写很多getter来暴露它们,而没法直接访问属性。
不对称可见性是什么
PHP 8.4 允许你给属性的读取和写入分别设置不同的可见性。写法是在属性声明时,增加一个括号,里面写写入的可见性。比如:
public private(set) string $orderNo;
这代表 orderNo 属性可以在任何地方被读取,但只能在类内部(private)被写入。相当于外部只能 get,不能 set。
更通俗点说:从外部看,它就像只读的public属性,但在内部,它就是普通属性,可以直接赋值。
改写上面的订单类
用了不对称可见性,上面的代码可以精简成这样:
class Order {
public private(set) string $orderNo;
public private(set) int $userId;
public private(set) float $amount;
public private(set) string $status = 'pending';
public function __construct(string $orderNo, int $userId, float $amount) {
$this->orderNo = $orderNo;
$this->userId = $userId;
$this->amount = $amount;
}
public function markPaid() {
$this->status = 'paid';
}
}
现在,外部代码可以直接读取订单号:
$order = new Order('A1001', 42, 299.00);
echo $order->orderNo; // A1001
// $order->orderNo = 'X'; // 这行会抛 Error,因为外部不能写
同时,类内部方法 markPaid() 可以修改 status,因为写入权限是 private,类内允许。其他字段根本不需要setter,一旦创建就不可变,非常安全。
“public private(set)” 到底怎么读
很多教程会写成 public private(set) string $name,其实意思是:读取可见性为 public,写入可见性为 private。语法上,你还可以写成 protected private(set),表示外部无法读取,只能在类内部读取和写入,这其实和 private 属性没区别。更常用的还有 public protected(set),表示属性外部能读,子类能写,外部不能写。
和 readonly 的区别
PHP 8.2 开始有 readonly 类,但 readonly 属性只能初始化一次,之后完全不能改。对于需要“一次性初始化,之后不变”的场景够用。但有时你希望部分字段不可变,而另一部分字段需要内部动态更新。比如状态、更新时间等。
不对称可见性更细粒度,它可以让你做到:有些字段完全不变,有些字段只能内部变。而 readonly 则是全变或全不变。
举个例子,一个订单类,订单号和用户ID创建后不变,但状态可能会从 pending 变成 paid。用 readonly 不行,因为状态需要变。用不对称可见性就完美:给订单号和用户ID用 public private(set),给状态用 public private(set) 也允许在内部更新,或者直接 public 让外部随便改(当然不推荐)。
实战:实现一个不可变又灵活的用户值对象
再看个用户场景。用户注册后,邮箱和用户名不能改,但密码可以内部哈希更新,头像允许外部修改(这是一个业务规则示例)。用不对称可见性可以这样设计:
class User {
public private(set) string $username;
public private(set) string $email;
public private(set) string $passwordHash;
public protected(set) string $avatar = 'default.png';
public function __construct(string $username, string $email, string $plainPassword) {
$this->username = $username;
$this->email = $email;
$this->setPassword($plainPassword);
}
// 内部方法可以修改密码
public function setPassword(string $newPassword) {
$this->passwordHash = password_hash($newPassword, PASSWORD_DEFAULT);
}
// 子类可以修改头像(protected set)
}
在这里,外部可以读取用户名和邮箱,但不能修改。密码虽然是 private set,但通过公有的 setPassword 方法修改,而不是直接操作属性。这样保持了封装,又少了 getter 的冗余。
继承和多态下的表现
如果子类想扩大写入可见性,是不允许的。比如父类属性是 public private(set),子类不能把它变成 public protected(set),因为可见性只能更严格,不能更宽松。反过来,如果父类是 public protected(set),子类可以改成 public private(set),更严格。这符合Liskov替换原则。
坑点:不能和 static 一起用
不对称可见性目前只适用于实例属性,不能用于静态属性。官方文档明确说了静态属性不支持这个语法,如果你写了 public static private(set) int $counter 会报语法错误。静态属性还是老老实实把访问器和修改器写成方法吧。
构造器里的初始化
在构造器里给这些属性赋值是没问题的,因为构造器本身就属于类内部。即使属性定义成 private(set),类内方法照样可以写。这也是它和 readonly 的区别:readonly 属性只能在声明时或构造器里赋值一次,而 private(set) 属性可以在类内任意方法中多次赋值。
反射支持
我测试了反射,ReflectionProperty 可以获取到属性的读写可见性。比如 getReadVisibility() 和 getWriteVisibility(),但这两个方法目前还是独立的。如果你在做依赖注入容器或者框架,得注意一下,但日常业务通常用不上。
最终完整示例
<?php
class Order {
public private(set) string $orderNo;
public private(set) int $userId;
public private(set) float $amount;
public private(set) string $status = 'pending';
public function __construct(string $orderNo, int $userId, float $amount) {
$this->orderNo = $orderNo;
$this->userId = $userId;
$this->amount = $amount;
}
public function markPaid() {
$this->status = 'paid';
}
public function refund() {
$this->status = 'refunded';
}
}
$order = new Order('ORD-2025-001', 88, 199.9);
echo $order->orderNo . "n";
echo $order->status . "n"; // pending
$order->markPaid();
echo $order->status . "n"; // paid
// $order->orderNo = 'X'; // 错误:Cannot access private property
// $order->status = 'cancelled'; // 错误:外部无法写
?>
这段代码跑在 PHP 8.4 上,输出正常。
更实际的场景:DTO 与查询参数
现在我在做 API 时,喜欢用这种语法定义请求 DTO。比如创建订单的请求对象:
class CreateOrderDTO {
public private(set) string $productId;
public private(set) int $quantity;
public private(set) string $couponCode = '';
public function __construct(array $data) {
$this->productId = $data['product_id'];
$this->quantity = (int)$data['quantity'];
if (!empty($data['coupon_code'])) {
$this->couponCode = $data['coupon_code'];
}
}
}
这样外部就不能篡改 DTO 中的数据,保证进入业务层的数据是稳定的。如果某个字段需要计算或加工,就通过内部方法完成,外部只读。
小结
PHP 8.4 的不对称可见性不是颠覆性的功能,但它让代码更简洁,表达意图更明确。以前你要么牺牲封装把属性设 public,要么写一堆 getter。现在你可以把“只读”这个语义直接写进属性定义里,类里面也保留了写入的自由。这个特性特别适合值对象、DTO、配置类等场景,我觉得它比 readonly 类更实用。如果你已经升级到 PHP 8.4,不妨试着把项目里那些只有 getter 的类改一改。

