発生している状況
10年前のコードベースをPHP 8.2に移行している最中に壁に突き当たりました。アプリをモダン化するために、DTO(Data Transfer Object)にreadonlyプロパティを導入しました。単純なリクエストでは完璧に動作しましたが、5,000件のユーザーレコードを定期同期している最中にクラッシュしてしまいました。
アプリケーションは処理の途中で、次のような特定の、そして厄介なメッセージを出して停止しました。
Fatal error: Uncaught Error: Cannot modify readonly property UserProfile::$username in /var/www/html/src/Models/UserProfile.php:14
PHP 8.1のreadonly修飾子は厳格です。一度値をセットすると、プロパティはロックされます。初期化ができるのは一度だけで、定義されたクラス内からのみ可能です。その後の変更は、たとえ全く同じ値を代入しようとしたとしても、Fatal Errorを引き起こし、スクリプトを即座に停止させます。
デバッグのプロセス
このエラーは通常、ループ処理や状態の更新中に発生します。今回のケースでは、ロジックはおおよそ次のようになっていました。
class UserProfile {
public readonly string $username;
public function setUsername(string $name) {
$this->username = $name; // $usernameが未初期化の場合のみ動作する
}
}
$profile = new UserProfile();
$profile->setUsername('jdoe');
$profile->setUsername('smith'); // FATAL ERROR: すでにロックされています
デバッガーは2回目のsetUsername呼び出しを直接指し示していました。代入がクラスメソッド内で行われていても、'jdoe'が代入された瞬間にプロパティは不変(immutable)になります。これは、readonlyプロパティをunset()しようとしたり、外部スコープから変更しようとしたりした場合にも発生します。
エラーを修正する解決策
1. コンストラクタのプロパティ昇格を利用する
最もクリーンな修正方法は、オブジェクト生成時に初期化を処理することです。コンストラクタのプロパティ昇格(Constructor Promotion)を使うと、ボイラープレートを削減でき、オブジェクトがメモリに生成される際にプロパティが一度だけ設定されることを保証できます。これはモダンなPHPのDTOにおける標準的なアプローチです。
class UserProfile {
// PHP 8.1以降の短縮構文
public function __construct(
public readonly string $username
) {}
}
// 使い方は安全でアトミックになります
$profile = new UserProfile('jdoe');
2. 「Wither」パターンを実装する
もし実際に値を変える必要がある場合はどうすればよいでしょうか?イミュータブルな設計では、既存のオブジェクトを変更しません。代わりに、更新されたデータを持つ新しいインスタンスを返します。これにより、1万行に及ぶアプリケーション全体で、元のオブジェクトの状態を予測可能な状態に保つことができます。
class UserProfile {
public function __construct(
public readonly string $username,
public readonly string $email
) {}
public function withUsername(string $newUsername): self {
// 新しい値でコピーを作成して返す
return new self($newUsername, $this->email);
}
}
$profile = new UserProfile('jdoe', 'jane@example.com');
$updatedProfile = $profile->withUsername('smith');
// 元の $profile は 'jdoe' のまま保持される
3. PHP 8.2 のクローン機能を活用する
PHP 8.2以降を使用している場合、このルールには特定の例外があります。__clone()メソッドの実行中に限り、readonlyプロパティを再初期化できるようになりました。これは、コピー時に新しいタイムスタンプが必要な複雑なオブジェクトやレポートにおいて非常に便利です。
class Report {
public function __construct(
public readonly DateTime $createdAt
) {}
public function __clone() {
// PHP 8.2ではクローン作成中のこの一度限りの上書きが許可される
$this->createdAt = new DateTime();
}
}
4. Readonlyキーワードを再検討する
モデルを過剰に設計しないでください。statusやlastLoginのように、ユーザーのセッション中に頻繁に変更されるプロパティであれば、readonlyは適切なツールではありません。単純にキーワードを削除しましょう。readonlyの厳格な不変性に縛られず、標準的なゲッターとセッターを持つプライベートプロパティを使用して制御を維持してください。
動作確認
本番環境にデプロイする前に、小さなCLIスクリプトを実行してこれらの修正を確認します。私のチェックリストは以下の通りです。
- オブジェクトをインスタンス化し、初期値を確認する。
- 「Wither」メソッドやクローンを実行する。
- `var_dump()`を使用して、元のオブジェクトが変更されていないことを確認する。
- スクリプトが終了コード0で終了することを確認する(Fatal Errorが発生していないことを意味します)。
まとめ
Readonlyプロパティはデータの整合性を保つのに強力ですが、状態管理の方法をシフトさせる必要があります。もしこのエラーに頻繁に遭遇するなら、設計をイミュータビリティ(Witherパターン)へ移行するか、よりシンプルなカプセル化されたプロパティを利用すべきかもしれません。予期しない二重代入を避けるために、可能な限りコンストラクタのプロパティ昇格を使用しましょう。

