前言
先做一个版本澄清,这是本文最重要的一句话:readonly属性是 PHP 8.1 引入的,不是 8.3。PHP 8.2 增加了"只读类"(readonly class),PHP 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 | 类中所有已声明的实例属性都变成只读 |
| 匿名类可标记为 readonly | PHP 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_types=1); 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_types=1); 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 class(PHP 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_types=1); 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 |
克隆并改值(免__clone) | clone($obj, ['prop' => $v]) | 8.5 |
| 深度不可变 | 只读属性 + 不可变属性类型 | — |
readonly是一个"赋值一次"的约束,不是一个"不可变对象"的保证。规范用它的方式很朴素:值对象与 DTO 整个类标记为readonly,改值一律返回新实例,属性类型全部选不可变的;实体类、需要 lazy loading 的类、会被代理继承的类,则只给个别属性加readonly。把这条边界划清楚,就不会再遇到"明明加了 readonly 还是被改了"的困惑。