☰
PHP 8.3的只读属性怎么用才规范
2026/10/2 9:45:20 网站建设 项目流程

前言

先做一个版本澄清,这是本文最重要的一句话: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类中所有已声明的实例属性都变成只读
匿名类可标记为 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_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 还是被改了"的困惑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询