
前言先做一个版本澄清这是本文最重要的一句话readonly属性是 PHP 8.1 引入的不是 8.3。PHP 8.2 增加了只读类readonly classPHP 8.3 在这个主题上只补了一项修正——允许在__clone()里重新初始化只读属性。所以标题里的 8.3 指的是这道克隆修正而不是只读属性本身本文按真实版本讲示例代码也会标出每个写法所需的版本。只读属性出问题时的症状非常有辨识度通常是这几条报错Cannot initialize readonly property Foo::$bar from global scope在类外给只读属性赋值、Cannot modify readonly property Foo::$bar第二次赋值或者构造之后再改、Readonly property Foo::$bar cannot have default value声明处写了默认值。更麻烦的是另一类不报错但不符合预期的情况有人以为readonly能把对象彻底冻结于是把readonly User $owner当成不可变保证结果外面照样能改$best-owner-nickname也有人给整个实体类加上readonly然后发现 ORM 的代理类生成失败了。这些问题的根源是把readonly理解成了深度不可变而它实际保证的只是这个属性只能被赋值一次且赋值必须发生在声明它的类作用域内。一、版本对照与四条硬规则特性真实版本说明readonly属性PHP 8.1单个属性只读必须在声明它的类作用域内初始化readonly类PHP 8.2类中所有已声明的实例属性都变成只读匿名类可标记为 readonlyPHP 8.3与只读类同时期的补丁__clone()中可重新初始化只读属性PHP 8.3即Readonly amendments每属性仅允许一次clone($obj, [prop $v])语法PHP 8.5无需写__clone()就能克隆并改值只读属性有四条不能碰的硬规则全部是编译期或运行期的错误而不是警告规则反例报错必须有类型public readonly $name;致命错误只读属性必须声明类型不能有默认值public readonly int $page 1;致命错误只读属性不能有默认值不能是静态属性public static readonly int $x;解析错误static与readonly互斥只能在类内初始化一次$obj-prop 1;类外Cannot initialize readonly property ... from global scope第二条常被误解不能有默认值限制的是属性声明处。构造函数属性提升PHP 8.0的参数默认值是允许的因为它本质上是构造参数的默认值?php // 需 PHP 8.1 final class Page { public function __construct( public readonly int $number, public readonly int $size 20, // 合法这是参数的默认值不是属性的默认值 ) {} public function withNumber(int $number): self { return new self($number, $this-size); // 8.1 / 8.2 下改值的唯一写法 } }二、只读保护的是引用不是值这是最容易踩、后果最隐蔽的一条readonly只禁止重新给属性赋值不禁止修改属性所指向对象的内容。?php // readonly_demo.php —— 需 PHP 8.1 declare(strict_types1); final class Profile { public string $nickname anon; // 可变属性用于演示浅不可变 } final class Account { public function __construct( public readonly array $roles, public readonly Profile $profile, ) {} } $account new Account([user], new Profile()); // 1) 只读属性本身不能重新赋值 try { $account-roles [admin]; } catch (Error $e) { echo 1) , $e-getMessage(), PHP_EOL; // Cannot modify readonly property Account::$roles } // 2) 数组内容也不能通过属性直接追加 try { $account-roles[] admin; } catch (Error $e) { echo 2) , $e-getMessage(), PHP_EOL; // Cannot modify readonly property Account::$roles } // 3) 但对象内部照样能改只读保护的是引用不是深层的值 $account-profile-nickname alice; echo 3) , $account-profile-nickname, PHP_EOL; // alice不同属性类型下readonly的实际强度属性声明能否重新赋值能否改内容readonly int/readonly string不能标量没有内容等价于完全冻结readonly array不能不能通过$obj-prop[]改取出副本后可以随便改副本readonly SomeObject不能能对象自身的方法与公开属性都不受限readonly DateTimeImmutable不能不能因为该对象自身设计为不可变改值只能返回新实例结论很直接想要真正的不可变光加readonly不够属性的类型本身也必须是不可变的。数组要换成只读集合对象或干脆不对外暴露DateTime要换成DateTimeImmutable自定义对象要保证它内部没有可变状态。三、规范用法值对象 with-er 模式只读属性最适合的场景是值对象Value Object与 DTO。规范写法有三条共识类声明为final避免子类绕过语义、全部属性只读、需要改值时返回新实例而不是原地修改。?php // 需 PHP 8.1 declare(strict_types1); final class Money { public function __construct( public readonly int $amount, // 以分为单位避免浮点误差 public readonly string $currency, ) { if ($amount 0) { throw new InvalidArgumentException(金额不能为负); } } public function withAmount(int $amount): self { return new self($amount, $this-currency); } public function add(self $other): self { if ($other-currency ! $this-currency) { throw new InvalidArgumentException(币种不一致不能相加); } return new self($this-amount $other-amount, $this-currency); } public function __toString(): string { return sprintf(%s %s, number_format($this-amount / 100, 2, ., ), $this-currency); } } $price new Money(2599, CNY); echo $price-withAmount(1999), PHP_EOL; // 19.99 CNY echo $price-add(new Money(1, CNY)), PHP_EOL; // 26.00 CNY整个类也可以用readonly classPHP 8.2一次性声明省掉每个属性上的关键字?php // 需 PHP 8.2 readonly class Color { public function __construct( public int $red, public int $green, public int $blue, ) {} public function withRed(int $red): self { return new self($red, $this-green, $this-blue); } }只读类的额外约束要知道成员属性必须有类型、不能有静态属性、不能有动态属性、不能使用#[\AllowDynamicProperties]而且只有只读类才能继承只读类。最后一条正是它和 ORM 冲突的原因。四、PHP 8.3 的克隆修正怎么用在 8.3 之前clone一个带只读属性的对象时如果需要在副本上调整字段只能重建整个对象。8.3 起可以在__clone()方法体内对只读属性重新赋值——每个属性仅允许一次且只能发生在__clone()执行期间原对象不受影响。?php // 需 PHP 8.3 declare(strict_types1); readonly class Snapshot { public function __construct( public string $label, public DateTimeImmutable $takenAt, public array $payload [], ) {} public function __clone(): void { // 8.3 起允许副本生成时刷新采集时间原对象保持 2026-01-01 不变 $this-takenAt new DateTimeImmutable(); } } $first new Snapshot(daily, new DateTimeImmutable(2026-01-01)); $second clone $first; printf(原始: %s\n, $first-takenAt-format(Y-m-d)); printf(副本: %s\n, $second-takenAt-format(Y-m-d));这段代码在 8.1 和 8.2 上会抛Cannot modify readonly property Snapshot::$takenAt。要注意修正的边界在__clone()之外包括类内的普通方法、类外的任何地方对已初始化的只读属性赋值依然会报错同一个属性在__clone()里连续赋值两次第二次同样报错。如果你的版本是 8.5 及以上还有一种更直观的写法——clone()变成了函数可以带第二个数组参数直接指定要改的属性不必依赖__clone()?php // 需 PHP 8.5 readonly class Color { public function __construct( public int $red, public int $green, public int $blue, ) {} } $blue new Color(79, 91, 147); $lighter clone($blue, [red 179, green 191]); // 8.5 的 clone with 语法 var_dump($lighter-red, $lighter-green, $lighter-blue); // 179, 191, 147常见坑点❌ 在属性声明处写默认值public readonly int $page 1;✅ 只读属性不能有默认值改为通过构造参数提供public function __construct(public readonly int $page 1) {}❌ 声明无类型的只读属性public readonly $name;✅ 只读属性必须带类型声明这是编译期致命错误不是警告❌ 写完readonly就认为整个对象不可变✅readonly User $owner只冻结了引用$obj-owner-nickname x依然合法。要深度不可变属性类型本身必须不可变如DateTimeImmutable、只暴露只读视图❌ 给整个 ORM 实体类加readonly✅ Doctrine 一类 ORM 会生成代理类继承实体而非只读类不能继承只读类代理生成会直接失败。正确做法是只给需要保护的属性单独加readonly❌ 用反射setValue()给已初始化的只读属性赋值做数据水合✅ 会抛Error。水合必须通过构造函数完成或者在水合器里使用newInstanceArgs()见数据转换的常规做法❌ 在__clone()之外给克隆出来的对象重新赋值指望 8.3 的修正生效✅ 修正只在__clone()方法体内生效且每个属性只能重新初始化一次类外的赋值依然报错❌ 在只读类上依赖动态属性$obj-extra 1;✅ 只读类禁止动态属性也不能通过#[\AllowDynamicProperties]打开需要额外字段就用数组属性或子类显式声明❌ 只读属性里放可变集合以为只读能防止集合被改✅readonly array $items挡不住foreach之后对副本的修改也挡不住把集合对象内部改掉。对外提供的应该是只读集合对象或者每次返回不可变副本总结关注点正确做法最低版本单个属性只读属性前加readonly且必须在类作用域内初始化8.1整个类只读readonly class属性需带类型、无静态属性、不含动态属性8.2构造参数默认值用属性提升的参数默认值而不是属性默认值8.0需要改值返回新实例with-er 模式8.1克隆时要改只读值在__clone()内重新初始化每属性一次8.3克隆并改值免__cloneclone($obj, [prop $v])8.5深度不可变只读属性 不可变属性类型—readonly是一个赋值一次的约束不是一个不可变对象的保证。规范用它的方式很朴素值对象与 DTO 整个类标记为readonly改值一律返回新实例属性类型全部选不可变的实体类、需要 lazy loading 的类、会被代理继承的类则只给个别属性加readonly。把这条边界划清楚就不会再遇到明明加了 readonly 还是被改了的困惑。