登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  php教程

PHP readonly 对象适合配置值还是领域实体

来源:17golang原创

时间:2026-10-08 21:53:13 178浏览 收藏

PHP readonly 对象更适合配置快照和值对象,而不是默认用来承载会不断改变状态的领域实体。判断标准不是“这个类重要不重要”,而是它创建后是否应该保持同一组值:连接配置、金额、日期范围、标识符通常适合;订单、购物车、工单这类有身份和生命周期的实体,通常保留可变状态更自然,再把其中的金额、编号等局部类型设计成 readonly 值对象。

PHP 官方手册:https://www.php.net/manual/en/language.oop5.basic.php

选型结论
  • 创建后只读取、修改时整体替换:优先 readonly class。
  • 需要 pay()、cancel() 等状态迁移:实体本身通常不要整体只读。
  • 属性里保存普通对象时只是“引用不能替换”,对象内部仍可能变化。
  • PHP 8.2 开始支持只读类;PHP 8.3 可在 __clone() 中重新初始化只读属性以完成深克隆。

先确认 readonly class 真正限制了什么

从 PHP 8.2 起,类可以声明为 readonly。它会让所有实例属性都具有只读约束,并禁止动态属性。类中的属性必须有类型,不能声明静态属性;属性初始化后不能再次赋值、递增、通过引用修改或 unset。

timeoutSeconds = 30;

只读类还限制继承关系。编写库代码时,不要只考虑当前类能否工作,还要考虑调用方是否需要通过子类增加可变属性。对稳定的小类型,这个限制通常有利;对扩展点较多的框架基类,它可能过早锁死设计。

配置和值对象为什么更适合 readonly

配置对象的职责通常是把一组已经解析、校验过的参数交给服务使用。业务运行期间如果要改变配置,更清晰的方式是重新生成一个新对象并替换旧快照,而不是让多个组件共享一个会被悄悄修改的实例。

HttpClientConfig 配置快照和 Order 领域实体的静态组成对比
图1:配置快照与领域实体的静态组成对比,不是运行截图。
baseUrl, $seconds, $this->retryPolicy);
    }
}

withTimeout() 的优点是调用点能看出“创建新值”,测试也不必担心其他对象持有的配置被联动修改。金额、邮箱地址、坐标、日期范围和业务编号同样符合这个特征:相等性主要由值决定,修改意味着得到另一个值。

readonly 是属性槽只读,不是递归冻结

最容易误判的地方是对象属性。只读属性保存一个对象时,PHP 限制的是属性槽不能改绑到另一个对象;被引用对象的内部状态仍可能变化。官方手册把这种情况称为内部可变性。

readonly UserView 的属性槽与 Address 对象内部状态之间的静态边界
图2:readonly 属性槽与对象内部状态的静态边界说明图,不是运行证据。
roles[] = 'admin';

// Address 引用没有被替换,但普通对象内部仍可修改。
$view->address->city = 'Shanghai';

因此,如果配置对象需要真正接近深度不可变,它引用的子对象也应设计成 readonly,或使用 DateTimeImmutable 这类不可变类型。仅把最外层类加上关键字,不能自动冻结整张对象图。

PHP 8.3 增加了一个实用边界:在 __clone() 中可以重新初始化只读属性,便于克隆时把内部对象也克隆一份。这个能力适合“复制后调整”的值对象,但依然要明确哪些子对象需要深克隆,不能把普通 clone 当成自动深复制。

有身份和生命周期的领域实体通常保留可变状态

领域实体与值对象的差别在于身份和生命周期。订单即使状态从“待支付”变成“已支付”,仍然是同一个订单。若把整个 Order 声明为只读,每次状态变化都要创建新实例,还要重新处理 ORM 跟踪、事件、关联集合和并发版本,成本往往高于收益。

status !== OrderStatus::Pending) {
            throw new DomainException('当前订单不能支付');
        }
        if ($received != $this->total) {
            throw new DomainException('支付金额不匹配');
        }
        $this->status = OrderStatus::Paid;
    }
}

这是一种更实用的组合:实体的 status 可以在受控方法内变化,身份和金额使用只读值对象,外部仍不能直接篡改关键属性。它既保留领域行为,也把真正不该变化的部分锁住。

什么时候领域实体也可以整体 readonly

整体只读并不是绝对错误。如果项目采用事件溯源、函数式建模,或实体更新本来就通过“旧对象 + 事件 = 新对象”完成,那么返回新实例可能正是设计目标。只要持久化层、集合关联和并发控制都按不可变模型实现,readonly 可以帮助语言层表达约束。

采用前至少回答四个问题:

  • 状态变化是否天然返回新实例,而不是依赖 ORM 脏检查?
  • 关联对象是否同样不可变,还是会造成浅只读错觉?
  • 新实例如何保留同一业务身份和并发版本?
  • 事件发布、序列化和缓存键是否能正确处理对象替换?

如果这些问题没有明确答案,优先选“可变实体 + readonly 值对象”,通常更容易与现有 PHP 框架和 ORM 协作。

选型速查表

对象类型整体 readonly推荐方式
环境配置、HTTP 客户端参数适合构造时校验,变更时生成新快照
Money、OrderId、Email、DateRange适合让值对象自校验并保持不可变
DTO、查询结果视图通常适合注意内部对象的浅只读问题
Order、Cart、Ticket 等实体通常不适合用行为方法控制状态,把关键字段做成 readonly 值对象
事件溯源中的不可变聚合快照可以确认持久化、版本和关联都支持对象替换
框架扩展基类谨慎先评估继承、Trait 属性和扩展需求

常见误区

readonly 等于 const 对象吗?

不完全等同。它约束属性初始化后的重新赋值,但对象属性指向的普通对象仍可能修改内部状态,所以不是递归常量。

readonly 属性可以保存数组吗?

可以,但初始化后不能追加、删除或修改数组元素,因为这些操作属于对只读属性的间接修改。

readonly class 可以有方法吗?

可以。方法可以执行计算、校验并返回新对象,只是不能在初始化完成后重新写入只读属性。

使用 readonly 后还需要 private 吗?

仍然需要按封装目标选择可见性。只读解决“能否重新赋值”,可见性解决“谁能读取或在允许的初始化作用域内设置”。两者职责不同。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>