
前言先纠正一个流传很广的版号错误readonly类readonly class是 PHP 8.2 引入的不是 8.3。PHP 8.1 引入的是readonly属性在属性前加readonly关键字到了 8.2 才允许把readonly写在class前面一次性把类里所有属性都变成只读。本文按 8.2 讲。如果你按标题里的 8.3 去写代码在 8.2 环境上其实也能跑但如果你以为8.1 也支持那就会在解析阶段直接失败。典型症状是这些给实体类加readonly之后框架的 hydration数据填充突然报Cannot modify readonly property克隆一个对象想改一个字段结果抛错从别处抄来的readonly class在自己的机器上能跑在同事的机器上整个文件解析失败。根因在于readonly不是把属性变成常量而是把属性的写入限制在声明它的类作用域内的第一次初始化。理解了这个语义绝大多数坑都能提前避开。本文会从语义、初始化时机、值对象写法、反射与克隆四个方面讲清楚并给出可直接运行的示例最低版本 PHP 8.2。一、readonly class到底是什么readonly class Foo {}等价于给类里每一个属性都加上readonly同时带来几条硬性约束每个属性必须有类型声明不能写无类型的属性属性不能有默认值静态属性除外但静态属性根本不允许存在不能声明static属性不能加#[\AllowDynamicProperties]动态属性会报错只能被同样是readonly的类继承反过来说非readonly的类不能继承readonly类。最后一条经常被忽略。你写了一个readonly class Base然后class Child extends Base会直接Fatal error。子类必须也写成readonly class Child extends Base。二、初始化时机只有一次且必须在类作用域内readonly属性的赋值只允许发生在声明它的那个类的作用域里并且只能成功一次。最常见的写法就是构造函数属性提升final readonly class UserId { public function __construct(public int $value) {} }这里$value虽然在__construct里被赋的值但因为提升语法把它声明的类作用域和构造函数绑定了所以合法。关键点作用域比对象更重要。子类里不能给父类声明的readonly属性赋值——因为那不在声明它的类作用域内。而通过反射从外部强行赋值同样不行只要属性已经初始化过ReflectionProperty::setValue()也会抛Error。有一个官方文档明确记载的行为值得记住在声明它的类作用域内可以对readonly属性执行unset()让它回到未初始化状态然后就能再次赋值。这不是把它变成可写属性而是把初始化重来一次。它正是实现克隆后修改字段的技巧基础但请只在__clone()这类受控场景使用。三、值对象Value Object与不可变更新的写法readonly类最适合的场景是值对象和 DTO一旦构造完成就不再变化用withXxx()方法返回一个修改后的新对象而不是原地改。public function withAmount(int $amount): self { $clone clone $this; $clone-amount $amount; // 在类作用域内但属性已初始化 → 会抛错 return $clone; }上面这段是错的clone出来的对象属性已经初始化过了。正确做法是在__clone()里先把属性unset()掉再赋值。下面给出完整可运行的例子。另外要记住一个容易被误判的点readonly是浅不可变。属性里存一个对象那个对象自己的字段照样能改存一个数组你对数组做$obj-items[] x会被拒绝因为这是修改属性但如果数组元素本身是对象改那个对象是允许的。四、克隆、反射与序列化clone是浅拷贝且克隆后的readonly属性依然已初始化ReflectionProperty::isReadOnly()可以判断属性是否只读属性钩子/框架序列化常用json_encode()对readonly类完全正常它只是普通的公有属性serialize()也能正常工作反序列化时属性是通过内部机制恢复的不受readonly限制。如果一定要在__clone()里改字段PHP 8.5 的clone with表达式是更干净的办法但那要等到 8.5 才能用。下面是 8.2 的完整可运行版本?php // 最低版本PHP 8.2 declare(strict_types1); final readonly class Money { public function __construct( public int $amount, // 单位分 public string $currency CNY, ) { if ($amount 0) { throw new InvalidArgumentException(金额不能为负); } } public function withAmount(int $amount): self { // 用 unset 让属性回到未初始化状态再重新赋值 return $this-rebuilt(amount: $amount); } public function withCurrency(string $currency): self { return $this-rebuilt(currency: $currency); } private function rebuilt(?int $amount null, ?string $currency null): self { $clone clone $this; unset($clone-amount, $clone-currency); // 类作用域内回到未初始化状态 $clone-amount $amount ?? $this-amount; $clone-currency $currency ?? $this-currency; return $clone; } public function format(): string { return sprintf(%s %.2F, $this-currency, $this-amount / 100); } } $price new Money(1999); $discounted $price-withAmount(1599); echo $price-format(), PHP_EOL; // CNY 19.99 echo $discounted-format(), PHP_EOL; // CNY 15.99 echo ($price $discounted ? same : different), PHP_EOL; // different // 反射初始化前检查 $rp new ReflectionProperty(Money::class, amount); var_dump($rp-isReadOnly()); // bool(true)注意unset()只能对已经初始化的属性用对未初始化属性unset()是无操作随后赋值本来就合法。rebuilt()这种做法属于为了不可变而绕了一圈实际项目里更推荐用构造函数直接造新对象unset只在需要保留大量字段、只改一两个的场景下才划算。常见坑点1. 用反射或 ORM 给已初始化的readonly属性赋值❌$rp-setAccessible(true); $rp-setValue($obj, $v);——setAccessible()只能解决可见性解决不了readonly照样抛Error: Cannot modify readonly property。 ✅ 让 hydration 走构造函数或者用专门的未初始化时填充流程先newInstanceWithoutConstructor()再在类作用域内赋值。2. 在readonly类里声明静态属性❌readonly class Config { public static array $cache []; }——Fatal error。 ✅ 静态数据用类常量或独立类承载。3. 子类没加readonly❌readonly class Base {}class Child extends Base {}——Fatal error。 ✅ 子类一并写readonly class Child extends Base {}或者干脆给父类加final杜绝继承。4. 以为readonly是深不可变❌readonly class Order { public function __construct(public array $items) {} }之后改$order-items[0][price] 1;期望报错实际这个写法本身就会因为修改属性被拒绝但$order-items[0]-price 1;元素是对象时是能改的字段就偷偷变了。 ✅ 集合里的元素也用readonly类表达或者在构造时深拷贝并冻结。5. 克隆后直接改字段❌ 在withXxx()里$clone clone $this; $clone-amount 5;——抛Error。 ✅ 在__clone()或专门的私有方法里先unset($clone-amount)再赋值。6. 试图用__set()魔术方法兜住写入❌ 给readonly类写public function __set($n, $v) { $this-$n $v; }——既绕不过readonlyreadonly类也不允许动态属性反而多一层困惑。 ✅ 需要可变语义就别用readonly类改成普通类 不对称可见性public private(set)PHP 8.4之类更贴切的表达。7. 把无类型属性写进readonly类❌readonly class A { public $x; }——Fatal error报属性必须有类型。 ✅ 补上类型实在不确定用mixed但要注意属性类型为mixed时依然受readonly约束。总结写法引入版本说明readonly属性PHP 8.1单个属性只读需有类型readonly类PHP 8.2类内全部属性隐式只读标题里的 8.3 有误类作用域内unset()PHP 8.1 起让只读属性回到未初始化状态可重新赋值一次clone withPHP 8.5不可变更新的语法糖8.2 上不可用不对称可见性PHP 8.4需要外读内写时比readonly更合适readonly类的价值在于把这个对象不会再变写成编译期约束而不是注释里的君子协定。规范用它的核心只有三条约束住最低版本是 8.2、把初始化收敛到构造函数、需要改一个字段时返回新对象而不是想办法绕过只读。做到这三点框架和工具都会站在你这边。