readonly class 声明必须写在 class 关键字前,即 readonly class User { ... } 正确;class readonly User 或类体内声明 readonly 字段均错误;只读类属性仅可在构造函数中初始化,且不递归保证嵌套对象不可变。

readonly class 声明必须写在 class 关键字前
只读类不是“加个修饰符到类里”,而是整个类的声明语法变更。readonly 是关键字,必须紧贴 class 前面,中间不能有空格或换行,也不能放在命名空间或注释之后。
常见错误是把它当成属性修饰符一样塞进类体里,或者误写成 class readonly User(顺序反了):
-
readonly class User { ... }✅ 正确 -
class readonly User { ... }❌ 解析失败,Fatal error -
class User { public readonly string $name; }❌ 这只是普通只读属性,不是只读类 -
readonly class User extends BaseModel { ... }✅ 支持继承,但父类本身不必是 readonly
只读类的属性只能在构造函数中初始化
只读类的所有属性(无论 public/protected/private)自动获得 readonly 语义,且仅允许在 __construct() 中赋值一次——哪怕你没显式声明 readonly,它也生效。
这意味着你不能再靠 setter、后期赋值或反射绕过限制:
立即学习“PHP免费学习笔记(深入)”;
- 构造函数参数若用属性提升(PHP 8.0+),会自动绑定为只读属性:
public function __construct(public string $name, private int $id) { } - 不能在构造函数外赋值:
$user->name = 'new';→Fatal error: Cannot assign to readonly property - 不能在
__clone()或__wakeup()中重新赋值,PHP 8.3 开始这些方法里对只读属性赋值也会被 PHPStan 标为assign.readOnlyProperty
只读类不等于完全不可变,嵌套对象仍可变
只读类只保证“属性引用不可变”,不递归冻结其值。如果属性是数组、对象或资源,内部状态仍可能被修改。
例如:
readonly class Config {
public array $options;
public DateTimeImmutable $createdAt;
public function __construct(array $options) {
$this->options = $options;
$this->createdAt = new DateTimeImmutable();
}
}
$config = new Config(['debug' => true]);
$config->options['debug'] = false; // ✅ 合法:数组内容可改
$config->createdAt->modify('+1 day'); // ❌ 报错:DateTimeImmutable 方法返回新实例,原对象不变,但这里只是调用,不涉及赋值
所以真正需要深度不可变时,得配合 DateTimeImmutable、Stringable 实现或自定义不可变集合——只读类只是第一道防线,不是银弹。
测试只读类时别 mock 构造函数参数
只读类通常用于 DTO 或值对象,测试重点应是“构造即验证”。用 createMock() 并禁用构造函数会导致所有属性为 null,而直接赋值又触发只读错误。
更务实的做法是跳过 mock,直接 new 实例:
- 传入真实依赖或
createStub()(不需要行为验证时) - 避免给只读类加业务逻辑方法;如有,用真实对象测,而非 mock 属性
- 若真需模拟(比如依赖外部服务),把可变部分抽到协作对象里,只读类只负责持有和暴露
最常被忽略的一点:只读类无法被 serialize() + unserialize() 安全还原,因为反序列化会绕过构造函数,导致只读属性未初始化。PHP 8.2+ 要求只读类实现 __unserialize() 手动校验,否则运行时报错。



















