PHPのreadonlyプロパティ、実務でハマるところ
readonlyプロパティとは、PHP 8.1で追加された、一度だけ値を設定したらそれ以降の変更を禁止できるプロパティ宣言です。
DTOや値オブジェクトを書くとき、「このプロパティはコンストラクタで入れたらもう変えさせたくない」という場面、よくありますよね。以前はprivateにしてセッターを用意しない、という運用でごまかしていましたが、readonlyが来てからは言語レベルで縛れるようになりました。便利は便利なんですが、思ったより制約が細かくて、最初は何度か怒られました。
readonlyはどう書く?
プロパティ宣言にreadonlyを付けるだけです。コンストラクタプロモーションと組み合わせるのが一番よく見る形だと思います。
final class Money
{
public function __construct(
public readonly int $amount,
public readonly string $currency,
) {}
}
$price = new Money(1000, 'JPY');
echo $price->amount; // 1000
なぜこう書くかというと、生成時点で値が確定し、以後は絶対に変わらないことをクラス自身が保証してくれるからです。呼び出し側は「あとで誰かがこっそり書き換えていないか」を気にしなくてよくなります。
一度きり、しかも宣言したクラスの中からしか書けない
readonlyは「初期化は1回だけ」という制約で、しかもその初期化はプロパティを宣言したクラスのスコープ内からしか許されません。外から書き換えようとすると即エラーです。
$price->amount = 2000;
// Fatal error: Uncaught Error: Cannot modify readonly property Money::$amount
ここでハマりやすいのが、「クラス内なら何度でも書けるだろう」という思い込みです。実際はクラス内であっても、一度値が入ったプロパティに2回目の代入をするとやはりエラーになります。コンストラクタで入れた値を、同じクラスの別メソッドで「補正」しようとして落ちる、というのはよくある事故です。
型が必須、というのも忘れがち
readonlyプロパティは必ず型宣言が必要です。public readonly $amount;のように型を省略すると構文エラーになります。
class Bad
{
public readonly $value; // Fatal error: Readonly property must have type
}
普段型を書かない癖がついている人ほど、ここで最初につまずく印象があります。
なぜcloneすると怒られるのか
readonlyで一番実務的にハマるのが、cloneとの相性です。PHP 8.1・8.2の時点では、__clone()の中であってもreadonlyプロパティへの再代入は「2回目の初期化」とみなされてエラーになります。
final class Box
{
public function __construct(public readonly \DateTime $createdAt) {}
public function __clone(): void
{
$this->createdAt = clone $this->createdAt;
// PHP 8.1/8.2ではここでError: Cannot modify readonly property
}
}
これはPHP 8.3で仕様が変わり、__clone()の実行中に限っては、readonlyプロパティを1回だけ再初期化できるようになりました(unsetして未初期化に戻すのもOKです)。ただし__clone()の外では相変わらず変更不可のままです。個人的には、8.3より前のバージョンを触るときは「readonlyオブジェクトのディープコピーは素直にはできない」と割り切っておいた方が事故が少ないと思います。
readonlyクラスというまとめ方もある
PHP 8.2からは、クラス自体にreadonlyを付けて全プロパティを一括でreadonly扱いにする書き方も使えます。
readonly class Point
{
public function __construct(
public float $x,
public float $y,
) {}
}
プロパティ一つひとつにreadonlyを書く手間が省けて、DTOが多いプロジェクトだと地味に効きます。ただしreadonlyクラスは静的プロパティを持てず、プロパティは全部型付きである必要があるので、既存クラスにあとから付けようとすると引っかかることがあります。
まとめ
readonlyは「一度入れたら変えない」という意図をコードに直接書けるようになった点でありがたい機能ですが、「クラス内でも2回目の代入はできない」「型宣言が必須」「cloneでの再代入はPHP 8.3から」というあたりを知らずに使うと、思わぬところでErrorに出会うことになります。値オブジェクトやDTOを書くときは、まず一度作って壊れないことを確認してから、既存コードに広げていくのが安全だと思います。
よくある質問
Q. readonlyプロパティにデフォルト値は設定できますか?
A. できません。デフォルト値を持たせると「宣言時点で初期化済み」と扱われ、コンストラクタでの代入が2回目の初期化になってエラーになるためです。
Q. readonlyと引数のデフォルト値なしのオプショナルプロパティは両立しますか?
A. コンストラクタの引数自体にデフォルト値を持たせることは可能ですが、プロパティ側に直接デフォルト値を書くことはできません。値を渡さない場合の扱いはコンストラクタ内のロジックで決める必要があります。
Q. readonlyプロパティは配列やオブジェクトの中身の変更も防いでくれますか?
A. 防いでくれません。readonlyが禁止するのはプロパティそのものへの再代入だけで、プロパティが指すオブジェクトの内部状態(例えばDateTimeのメソッド呼び出しによる変更)までは制御できません。