在PHP里做真正的封装一直有点笨拙。想让一个属性外部可读但不可写,传统的做法是把它设为private,然后写一个getXxx()方法暴露读操作,不写setXxx()。如果只是那么一两个属性还好,但一个完整的实体对象可能有十多个字段,每个都要配一个getter,文件一下子就膨胀了。更别提有些属性允许在类内部修改、不允许子类修改、在外部只读——权限组合稍微复杂些,靠一个public或private根本表达不了。
PHP 8.4 带来的非对称可见性就是来解决这个问题的。它允许你分别声明一个属性“谁能读”和“谁能写”。比如可以写public private(set),意思是任何人都可以读它的值,但只有当前类自己可以修改。过去要六七个getter/setter才能表达的约束,现在一行属性声明就搞定了。这篇文章通过一个完整的用户实体案例,把非对称可见性从基本语法到实际应用拆解清楚。
过去我们怎么做封装
拿最常见的用户对象来说,通常有用户ID、注册时间这些“创建后就不该变”的字段,也有昵称、邮箱等可以修改但需要验证的字段。传统PHP代码会写成这样:
class User
{
private int $id;
private string $nickname;
private string $email;
private DateTimeImmutable $createdAt;
public function __construct(int $id, string $nickname, string $email)
{
$this->id = $id;
$this->nickname = $nickname;
$this->email = $email;
$this->createdAt = new DateTimeImmutable();
}
public function getId(): int
{
return $this->id;
}
public function getNickname(): string
{
return $this->nickname;
}
public function setNickname(string $nickname): void
{
if (strlen($nickname) nickname = $nickname;
}
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 = $email;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
}
这个类有四个字段,外加构造函数,已经占了大半页。而且getId和getCreatedAt其实不想让外部修改,但它们却和nickname、email一样都是private再配getter——声明上完全看不出它们的修改权限其实不一样。当你需要在一堆“只读”属性中分辨出少数几个可变属性时,只能靠记忆或注释。
非对称可见性的基本语法
PHP 8.4 允许在属性声明时使用public、protected、private 后接一个括号包含的set修饰符,来单独指定写操作的可见性。例如:
public private(set) int $id;
public protected(set) string $nickname;
public private(set) DateTimeImmutable $createdAt;
第一行意味着$id在类外部可以读(public读),但只能在类内部写入(private set)。子类也不能修改。第二行的protected(set)表示当前类和子类可以写入,外部不行。第三行同样是读公开、写私密。读的可见性仍然支持public、protected、private,如果没有显式声明读可见性,PHP会根据set的位置智能推断:如果set写在protected后面,读也会自动设为protected,这符合直觉。但为了可读性,一般建议把读和写的可见性都写全。
有了这个能力,上面的User类可以大幅精简:
class User
{
public private(set) int $id;
public private(set) DateTimeImmutable $createdAt;
public function __construct(
int $id,
public protected(set) string $nickname,
public protected(set) string $email
) {
$this->id = $id;
$this->createdAt = new DateTimeImmutable();
// nickname 和 email 由构造函数属性提升自动赋值
}
// 验证逻辑可以搬到属性钩子里,或保留setter方法作为写入入口
// 这里为了演示非对称可见性,先假设外部通过setter修改
public function changeNickname(string $nickname): void
{
if (strlen($nickname) nickname = $nickname;
}
public function changeEmail(string $email): void
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('邮箱格式不正确');
}
$this->email = $email;
}
}
现在外部代码可以读$user->id和$user->createdAt,但试图$user->id = 2会直接报错。$nickname和$email外部可读,但受protected(set)限制,外部不能直接赋值,必须通过changeNickname这类方法。这个权限在类声明时一眼就看得清楚,不用在getter/setter方法里猜测。
实际案例:一个更复杂的实体
现在把场景放大一点。一个订单模型,有订单号、创建时间、状态、支付方式、金额等字段。业务规则是:订单号和创建时间一旦创建不可变;状态只能在订单类内部修改(通过cancel、ship等方法改变状态),不允许外部或子类直接改;支付方式在创建时指定,但允许子类(如VipOrder)在特殊情况下修改;金额对外部只读,但允许内部和子类修改。此外,所有属性在外部都必须可以读取。
用非对称可见性表达这些规则:
class Order
{
public private(set) string $orderNo;
public private(set) DateTimeImmutable $createdAt;
public private(set) string $status = 'pending';
public protected(set) string $paymentMethod;
public protected(set) float $amount;
public function __construct(string $orderNo, string $paymentMethod, float $amount)
{
$this->orderNo = $orderNo;
$this->createdAt = new DateTimeImmutable();
$this->paymentMethod = $paymentMethod;
$this->amount = $amount;
}
public function cancel(): void
{
if ($this->status === 'shipped') {
throw new RuntimeException('已发货订单无法取消');
}
$this->status = 'cancelled';
}
public function ship(): void
{
$this->status = 'shipped';
}
}
class VipOrder extends Order
{
public function applyDiscount(float $discount): void
{
// 子类可以修改金额(因为amount是protected(set))
$this->amount -= $discount;
}
}
外部代码$order->orderNo能读,$order->orderNo = 'xxx'会报错。$order->paymentMethod能读,不能直接赋值,但VipOrder子类内部可以修改它。状态只能通过cancel和ship方法间接变更,外部连protected(set)的写入权限都没有。权限层级分明,而且都是在属性声明行表达的,不需要用getter或setter来“翻译”。
与属性钩子的配合
非对称可见性可以和前面文章介绍过的属性钩子一起使用,进一步加强控制。比如上面的$status属性,我们既希望外部只读、内部可写,又想在每次写入时自动记录日志。可以把非对称可见性和set钩子结合:
class Order
{
private string $_status = 'pending';
public private(set) string $status {
get => $this->_status;
set (string $value) {
// 自动记录状态变更
Logger::info("订单状态变更: {$this->orderNo} {$this->_status} → {$value}");
$this->_status = $value;
}
}
// ...其他属性
}
这里private(set)限制只有类内部能写入,而set钩子在每次写入时自动执行日志逻辑。外部调用$order->ship()方法,方法里$this->status = 'shipped'触发钩子和可见性检查,一切正常。外部直接$order->status = 'delivered'则会被可见性拦截,连钩子都进不去。
非对称可见性在属性提升中的应用
构造函数属性提升在PHP 8.0引入,8.4的非对称可见性也完美适配。你可以在构造函数参数里同时声明提升属性的读写权限:
class Product
{
public function __construct(
public private(set) string $sku,
public protected(set) float $price,
public private(set) DateTimeImmutable $listedAt = new DateTimeImmutable(),
) {}
}
这样$sku创建后不可修改,$price允许子类修改,$listedAt不可修改。代码紧凑且语义清晰。相比之前必须private string $sku外加一个getSku()方法,效率提升不止一点。
几个值得关注的行为细节
可见性检查发生在写入时。 非对称可见性在属性赋值时检查。$order->status = 'xxx'如果权限不够会抛出Error。反射操作ReflectionProperty::setValue可以绕过这个限制,和以前一样。如果希望绝对不可写,可以额外套一层自定义逻辑。
读可见性的默认行为。 如果只写了set的可见性而没有声明读的可见性,PHP会根据set的位置来推断。比如public private(set) string $id,读是public;而protected private(set) string $internal,读是protected。但为了代码一目了然,通常会把读也写全,像public private(set)。
和readonly的区别。 readonly属性只能在构造函数内或声明时赋值,之后连类内部都不能改。非对称可见性的private(set)则允许类内部任意方法修改。对于一个“初始化后还有状态变更”的属性,private(set)更灵活;对于像ID这种真的完全不可变的值,readonly更合适,或者用public private(set)加上内部也不允许修改,但那就需要自己在代码里约束。
应用建议与迁移策略
非对称可见性最适合用在数据对象、DTO、实体和值对象上。这些类通常对外只暴露部分修改入口,大部分属性是只读的。如果你正准备把项目升级到PHP 8.4,可以先从新建的类开始使用,旧代码不用急于重写。等团队成员都熟悉了新语法后,再逐步替换一些getter泛滥的类。
对于已经存在大量getter/setter的遗留类,替换时需要留意几点:直接暴露public属性后,调用方从$obj->getXxx()变成$obj->xxx,虽然直观,但对外的接口签名的确变了。如果有批量调用的代码,可以用IDE的重构功能批量替换,或者在类里暂时保留getter方法作为别名,兼容过渡期。
小结
非对称可见性解决的是一个存在了很久的小痛点——用一个关键字无法同时描述“读”和“写”两种不同的访问级别。它把原本隐藏在方法里或是注释里的权限意图直接搬到了属性声明上,让类的设计意图更加显式化。把可读性和可写性分开控制,配合属性钩子和构造函数提升,实体类的代码量可以减少一半以上,而且阅读起来更像是在看规格说明,而不是在看一堆样板代码。
如果你的项目准备或已经运行在PHP 8.4,这个特性值得第一时间用起来。毕竟少写getter这件事,没有人会拒绝。

