PHP 8.4 的 readonly 属性行为与 PHP 8.2 一致,仅允许在构造函数中赋值一次,禁止运行时任何写入(含反射、反序列化、__clone/__wakeup),不递归保护嵌套值,提供编译器级不可变保障。

PHP 8.4 的 readonly 属性不是新特性——它早在 PHP 8.2 就已引入,PHP 8.4 并未修改其行为。如果你在 PHP 8.4 环境下遇到 readonly 相关问题,大概率是升级后暴露了旧代码中对只读属性的非法写入,或误用了兼容性边界。
readonly 属性到底禁止什么操作
readonly 修饰的类属性,仅允许在构造函数(__construct)中赋值一次,之后任何写入(包括对象自身方法、反射、序列化还原后的赋值)都会抛出 Fatal error: Uncaught Error: Cannot modify readonly property。
- ✅ 允许:
class User { public readonly string $name; public function __construct(string $name) { $this->name = $name; // 唯一合法赋值点 } } - ❌ 禁止:
$user->name = 'new';、$user->__set('name', ...)、ReflectionProperty::setValue()、反序列化时覆盖该属性(即使unserialize()返回的对象含该字段) - ⚠️ 注意:只读性不递归——
public readonly array $data;中的$data['key'] = 'val'仍合法,因为修改的是数组元素,不是属性本身
为什么不能在 __clone 或 __wakeup 中重新赋值
PHP 明确禁止在 __clone 和 __wakeup 中给 readonly 属性赋值,哪怕你试图“重置”它。这是设计使然:只读属性代表值在对象生命周期内恒定,克隆/反序列化应产生语义等价的新实例,而非绕过约束。
- ❌ 错误写法:
public function __clone() { $this->id = uniqid(); // Fatal error } - ✅ 替代方案:改用普通属性 + 手动控制;或用工厂方法生成新实例:
User::fromArray([...]),而非依赖clone - ? 提示:若需“不可变但可克隆”,应把只读属性封装进 value object,并让容器类(如
User)持有它,克隆时新建该 value object
与 final class / const / __get 的关键区别
readonly 解决的是「实例级不可变」,和 const(类级常量)、final class(禁止继承)、__get(模拟只读访问)完全不在同一维度。
立即学习“PHP免费学习笔记(深入)”;
-
const NAME = 'foo';→ 所有实例共享,无法按实例定制 -
final class→ 阻止继承,不影响属性可变性 - 仅靠
__get+ 私有属性 → 只能拦截公共访问,无法阻止内部方法或反射修改 -
readonly→ 编译器级保护,连ReflectionProperty::setValue()都被拦截,且 IDE 和静态分析工具(如 PHPStan)能识别并报错
实际项目中最容易踩的坑
升级到 PHP 8.2+ 后,最常触发 readonly 报错的不是新代码,而是旧逻辑里隐式修改了本该只读的字段。
- ORM 映射:Doctrine 或 Laravel Eloquent 若将数据库字段映射为
readonly属性,但又在 hydrate 过程中尝试赋值(比如从查询结果 set),会直接崩溃 - DTO 构造后又被 setter 修改:例如
$dto = new OrderDto(...); $dto->status = 'shipped';—— 若status是readonly,这里就炸 - 测试 Mock:用
Mockery或PHPUnit的setMethods()尝试 mock 只读属性的 setter,会失败(因为根本不存在可覆盖的 setter) - JSON 序列化/反序列化:用
json_decode($json, true)再foreach赋值到readonly属性,必须确保只在__construct中做,否则运行时报错
真正要用好 readonly,得从建模阶段就决定哪些字段属于“身份标识”或“创建快照”,而不是把它当普通属性加个修饰符了事。一旦标为 readonly,就得接受它带来的刚性约束——包括测试方式、数据流转路径、甚至团队协作时的 API 设计习惯。











