一个常见的场景:订单创建后,订单号应该能外部读取,但不能被随意修改;订单金额只能由内部业务逻辑变更,外部代码连读都不允许;而某些内部状态值希望完全公开。过去为了实现这种精细的访问控制,我们写了一大堆getXxx、setXxx方法,或者滥用魔术方法,搞得类里到处是模板代码。
PHP 8.4引入的非对称可见性(Asymmetric Visibility)终于让这个问题有了优雅的解法。它允许你为属性的读和写分别指定不同的访问修饰符,比如public get, private set,一行声明就能搞定以前需要三四个方法才能完成的事情。这篇文章把这套新语法的用法、实际落地案例和需要注意的边界情况完整梳理出来。
一、非对称可见性的基本语法
在PHP 8.4之前,一个属性的可见性只有一套修饰符:public、protected或private,读写权限一样。非对称可见性允许写成这样:
<?php
class Order
{
// 所有人都能读,但只能在类内部修改
public private(set) string $orderNo;
// 类内部及子类可以读写,外部只能读取
public protected(set) string $customerEmail;
// 外部只能读,私有的写入权限
private(set) float $amount;
// 完全公开,读写一致
public string $currency = 'CNY';
}
语法规则很直接:在常规的可见性修饰符后面加上括号包裹的set权限。括号里的修饰符只控制写权限,前面的修饰符控制读权限。如果不写括号,则读写权限相同,与旧语法完全一致。
几种常见的组合方式:
public private(set)— 外部可读,仅类内部可写(最常用的“只读属性”)。public protected(set)— 外部可读,类内部和子类可写。private protected(set)— 读取也限制在类内部,写入可被子类共享。这种场景少见但存在。private(set)— 等价于public private(set),因为默认的读取权限是public。
注意:set权限不能比get权限更宽松。你不能写private public(set)——读是私有,写却是公开,逻辑上说不通。PHP会在编译时检查这种错误。
二、案例一:订单实体的属性控制
拿电商系统的订单模型来举例。订单有订单号、金额、状态、创建时间等字段,不同字段需要的权限完全不同:
- 订单号:外部系统需要读取(展示、打印),但绝对不能由外部修改。
- 金额:内部计算得出,不可在外部直接改写;但允许外部读取(用于显示)。
- 内部折扣率:完全是内部计算用的,外部完全不能接触。
- 状态:可以由特定的服务类来推进(比如从“待支付”变为“已支付”),但外部不能随意赋值。
用非对称可见性实现:
<?php
class Order
{
// 订单号:外部可读,内部可写(构造时赋值)
public private(set) string $orderNo;
// 金额:外部可读,但只能通过内部方法修改
public private(set) float $amount;
// 折扣率:完全对外隐藏
private float $discountRate = 0.0;
// 状态:外部可读,子类或内部可写
public protected(set) string $status = 'pending';
public function __construct(string $orderNo, float $originalAmount, float $discountRate)
{
$this->orderNo = $orderNo;
$this->amount = $originalAmount * (1 - $discountRate);
$this->discountRate = $discountRate;
}
// 调整折扣,同时重新计算金额——这是唯一能修改金额的入口
public function adjustDiscount(float $newRate): void
{
if ($newRate 0.9) {
throw new InvalidArgumentException('折扣率无效');
}
$this->discountRate = $newRate;
// 内部可以写 amount,因为 set 是 private
$this->amount = $this->getOriginalAmount() * (1 - $newRate);
}
// 状态推进,由专门的Service调用
public function markAsPaid(): void
{
if ($this->status !== 'pending') {
throw new RuntimeException('当前状态不可支付');
}
$this->status = 'paid';
}
private function getOriginalAmount(): float
{
// 反推原价,略去实现
}
}
外部代码试图直接修改$orderNo或$amount会直接触发致命错误,保护了数据的完整性。同时读取这些属性就像访问公开属性一样自然:echo $order->orderNo;,不需要getOrderNo()。
这种写法不仅减少了样板代码,还能让IDE的自动补全和类型检查更准确——属性就是一个带类型的变量,访问它没有任何方法调用的开销。
三、案例二:用户模型的读写权限分层
再来看一个用户实体的例子。用户的信息修改规则通常比较复杂:
- 用户名:注册后不可修改,但任何人都能看到。
- 邮箱:可以修改,但需要验证,修改后要记录变更日志。
- 密码哈希:绝对不能对外暴露,修改时需要通过专门的方法来加密。
用旧方法实现会写一堆getter和setter,新语法下的实现:
<?php
class User
{
public private(set) string $username;
public private(set) string $email;
private string $passwordHash;
public function __construct(string $username, string $email, string $plainPassword)
{
$this->username = $username;
$this->email = $email;
$this->passwordHash = password_hash($plainPassword, PASSWORD_DEFAULT);
}
// 邮箱修改必须走这个流程
public function changeEmail(string $newEmail): void
{
if (!filter_var($newEmail, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('邮箱格式无效');
}
$oldEmail = $this->email;
$this->email = $newEmail; // private(set) 允许内部写
// 记录变更日志
Logger::info("用户 {$this->username} 邮箱从 {$oldEmail} 变更为 {$newEmail}");
}
// 密码验证,但不暴露哈希
public function verifyPassword(string $plainPassword): bool
{
return password_verify($plainPassword, $this->passwordHash);
}
// 修改密码
public function changePassword(string $oldPlainPassword, string $newPlainPassword): void
{
if (!$this->verifyPassword($oldPlainPassword)) {
throw new InvalidArgumentException('原密码错误');
}
$this->passwordHash = password_hash($newPlainPassword, PASSWORD_DEFAULT);
}
}
外部代码可以$user->username直接读取,但赋值会报错。邮箱同理。密码哈希则完全对外不可见。这种设计让类的公共API变得非常清晰:哪些是只读数据、哪些可以通过方法修改、哪些完全隐藏,都在属性声明处一目了然。
四、与readonly修饰符的配合
PHP 8.1引入的readonly关键字可以让属性在初始化后不可再写入。但它有两个局限:一是只能设置“读写均为public”的只读属性,不能区分读和写的可见性;二是readonly属性在对象克隆时也不能修改,对于某些需要克隆后稍作调整的场景不够灵活。
非对称可见性与readonly可以共存,而且语义互补:
<?php
class ReadOnlyDto
{
// 完全公开且只读,初始化后不可变
public readonly string $id;
public readonly string $createdAt;
public function __construct(string $id)
{
$this->id = $id;
$this->createdAt = date('c');
}
}
但如果你希望某个属性“对外只读,但对内可写且可以多次修改”,就不能用readonly,只能用非对称可见性public private(set)。比如订单的状态字段,它在整个生命周期里会多次改变,但外部不能动。
五、在构造函数和内部方法中的写入规则
非对称可见性对构造函数的写入行为没有特殊限制。即使在类外部通过new实例化时传入构造参数,进而间接写入private(set)属性,也是允许的——因为构造器内部仍然属于“类内部”。
但如果你需要从另一个类(比如Factory)直接写入private(set)属性,那就不行。这个时候要么把那两个类放在同一个作用域(PHP的friend概念可以通过一些技巧实现,但官方并没有直接提供),要么就通过方法来修改。通常的实践是为这种跨类修改提供一个包级可见的静态构造方法,让创建逻辑内聚在类内部。
六、非对称可见性对继承的影响
子类在继承带有非对称可见性的属性时,可以维持或者放宽set权限,但不能收紧。比如父类定义了public protected(set) string $status,子类不能把它改成public private(set),因为那会收紧写入权限。但子类可以改成public public(set),即放宽写入权限。
这一点与传统的可见性继承规则一致:子类不能缩小父类成员的可见性。所以设计父类时如果拿不准将来子类是否需要写入,可以把set设成protected,给子类留出空间。
七、实际项目中的迁移策略
如果你的项目已经跑在PHP 8.4上,可以逐步用非对称可见性替换现有的getter/setter。一个稳妥的顺序是:
- 先处理那些明显“只读”的属性,比如ID、创建时间、唯一标识符。把它们从
private加getXxx()改成public private(set),同时删掉getter方法。 - 再处理有简单验证逻辑的setter。用非对称可见性配合一个命名良好的修改方法替代通用setter,让修改意图更明确。比如
setEmail改成changeEmail。 - 内部完全私有的属性保持
private不变,没必要为了用新语法而用。
迁移过程中IDE的重构功能会很方便——把private属性改成public private(set),然后内联getter的调用点,可以机械地完成大部分修改。
八、总结
非对称可见性是PHP 8.4中最能直接影响日常编码习惯的特性之一。它没有引入新的编程范式,只是把开发者早已在注释和getter/setter里表达的设计意图变成了语言的语法。这种“语法化的设计意图”让代码更不容易被误用,也让类的公共接口更加精确。
如果你的代码里有很多这样的模式:一把private属性配上getXxx和setXxx,就可以考虑换用public private(set)和专用修改方法。迁移的成本很低,但带来的代码清晰度和安全性的提升是立竿见影的。

