課題:従来の PHP ボイラープレートに伴うコスト
PHP においてクリーンなドメインモデルを記述する際、長年にわたりカプセル化と開発効率の間で歯がゆいトレードオフを強いられてきました。エンティティや Data Transfer Object(DTO)を作成するたびに、クラスの境界を越えて変数をやり取りするだけの防衛的なコードを延々と書く羽目になっていたのです。
外部の呼び出し元が内部状態を不用意に変更するのを防ぐため、プロパティを private や protected で保護したうえで、getter と setter のペアを量産するのが標準的なプラクティスでした。
<?php
class UserProfile
{
private string $username;
private int $reputationScore = 0;
public function __construct(string $username)
{
$this->setUsername($username);
}
public function getUsername(): string
{
return $this->username;
}
public function setUsername(string $username): void
{
$cleaned = trim($username);
if (strlen($cleaned) < 3) {
throw new InvalidArgumentException('ユーザー名は3文字以上である必要があります。');
}
$this->username = $cleaned;
}
public function getReputationScore(): int
{
return $this->reputationScore;
}
public function increaseReputation(int $points): void
{
if ($points > 0) {
$this->reputationScore += $points;
}
}
}
なぜこのようなことが起きていたのでしょうか?従来の PHP では二者択一の可視性しか強制できなかったためです。プロパティは完全に公開されているか、完全に隠蔽されているかのどちらかでした。public string $username として公開すれば呼び出し元に読み取りアクセスを許可できますが、同時にすべてのフォーマットガードやバリデーションチェックが失われてしまいます。1クラスあたり平均8個のプロパティを持つドメインエンティティが40個存在する本番コードベースでは、この制限だけで1,500〜2,000行もの無意味なアクセサコードによってプロジェクトが肥大化してしまうのです。
PHP における状態管理の進化
近年のメジャーリリースを通じて、状態管理の手法は着実に進化してきました。過去のアプローチとモダンな PHP 8.4 の比較は以下のとおりです。
| アプローチ | 読み書きアクセス | インラインバリデーション | ボイラープレートのオーバーヘッド |
|---|---|---|---|
| PHP 7.4 型付きプロパティ | 一律(public/protected/private) | 明示的な setter メソッドが必要 | 高(大量の手動 getter/setter) |
| PHP 8.1 Readonly プロパティ | 初期化後はイミュータブル | 更新時は不可(変更不可) | 低(ただしミュータブルな状態には使えない) |
PHP 8.2+ マジックメソッド(__get / __set) |
動的アクセスのシミュレーション | 可能(ただし静的型付けが無効化される) | 中(IDE のコード補完や静的解析の精度が低下) |
| PHP 8.4+ Hooks & 非対称な可視性 | きめ細かな制御(例: public private(set)) |
get / set フックを直接アタッチ |
極めて低(完全な型安全性と IDE サポート) |
従来の回避策が不十分だった理由
計算プロパティやアクセス制限プロパティをシミュレートするために、多くのチームが __get() や __set() といったマジックメソッドに依存していました。しかし、このアプローチには深刻なデメリットが伴いました。動的なプロパティのインターセプトはマイクロベンチマークで直接アクセスに比べて約3〜5倍遅く、IDE のコードナビゲーションを損ない、PHPStan や Psalm が型を正しく認識できるように独自の docblock アノテーションを記述する必要があったのです。
PHP 8.1 で導入された readonly プロパティは静的な DTO の課題を解決しましたが、ショッピングカートやサブスクリプション、ユーザープロファイルのようにライフサイクルを通じて状態が変化するドメインエンティティは、依然としてレガシーな getter/setter のノイズを抱えたままでした。
PHP 8.4 は、以下2つの重点的な言語機能の追加によってこの問題に正面から取り組んでいます。
- 非対称な可視性(Asymmetric Visibility): 読み取りと書き込みの権限を個別に制御します(たとえば、読み取りは public で許可しつつ、書き込みはクラス内部のみに制限するなど)。
- Property Hooks: プロパティ宣言にカスタムの読み取り(
get)および書き込み(set)ロジックを直接組み込みます。
メリットとデメリット:PHP 8.4 のプロパティ機能を評価する
主なメリット
- 保守すべきコードの大幅な削減: 一般的なドメインモデルのコード量を40〜60%削減できます。ファイル内が定型的なアクセサメソッドで埋もれず、ビジネス不変条件に集中できるようになります。
- 安全かつ段階的なリファクタリング: まずは単純な public プロパティから開始し、後からフックを導入できます。利用側は
$user->usernameのままアクセスを継続できるため、パブリック API を破壊しません。 - ネイティブなツールサポート: PHPStan 2.0+ などの静的解析ツールや PhpStorm などの IDE が、追加のプラグインや docblock の裏技なしで両機能をネイティブに解釈します。
- 自然な構文: 呼び出し元は直接代入を行う標準的なオブジェクトプロパティとして読み書きできるため、非常に直感的に利用できます。
実運用上のトレードオフ
- フックの肥大化リスク: フックは高速かつ軽量に保つ必要があります。
setフックの内部で HTTP リクエストやデータベースクエリ、重いドメインイベントを発火させると関心の分離に違反し、デバッグが困難な問題を引き起こします。 - コンストラクタプロモーションの制限: 非対称な可視性はコンストラクタプロモーション(Constructor Promotion)でスムーズに機能しますが、Property Hooks はコンストラクタのシグネチャ内に直接宣言することはできません。
- チーム内のコーディング習慣: 従来の PSR による getter 規約に慣れ親しんだ開発者の場合、
$order->getStatus()を呼び出す代わりにプロパティを直接読み取るスタイルに適応するまでにある程度の時間が必要になる可能性があります。
環境設定
両機能を使用するには PHP 8.4 以降が必要です。
1. PHP バージョンの確認
php -v
# 出力結果が PHP 8.4.0 以上であることを確認してください
2. Composer と品質管理ツールの設定
環境を PHP 8.4 に固定し、対応する静的解析およびテストライブラリを導入します。
{
"require": {
"php": ">=8.4.0"
},
"require-dev": {
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^11.0"
}
}
主要な請求エンティティをこれらの機能を用いてリファクタリングしたところ、プルリクエストの差分が数百行単位で削減されました。レビュアーは同一パターンの getter ボイラープレートを流し読みする代わりに、ビジネスルールの検証に時間を割けるようになりました。
実践的なステップバイステップ・リファクタリング
ステップ 1: 非対称な可視性の設定
注文処理モデルを例に考えてみましょう。ID、注文合計額、ステータスはテンプレートやレポート処理から読み取れる必要がありますが、書き込み操作はモデル内部に厳格に制限されるべきです。非対称な可視性を使用すると、宣言部でこれを直接設定できます。
<?php
declare(strict_types=1);
class Order
{
// 読み取りは完全公開、変更は Order クラス内部からのみ可能
public private(set) string $status = 'pending';
// どこからでも読み取り可能だが、更新は自クラスおよびサブクラスに制限
public protected(set) DateTimeImmutable $updatedAt;
public function __construct(
public private(set) int $id,
public private(set) float $amount
) {
$this->updatedAt = new DateTimeImmutable();
}
public function markAsCompleted(): void
{
$this->status = 'completed';
$this->updatedAt = new DateTimeImmutable();
}
}
$order = new Order(id: 1042, amount: 89.95);
echo $order->status; // 'pending' と出力(許可: public 読み取り)
// $order->status = 'cancelled';
// Fatal Error: Cannot modify private(set) property Order::$status from global scope(グローバルスコープから private(set) プロパティ Order::$status を変更することはできません)
ステップ 2: フックを使用したバリデーションと正規化の追加
入力値のトリミングやフォーマットバリデーションが必要な場合は、プロパティに直接 set フックと get フックをアタッチします。バッキングプロパティでは暗黙的な $value 引数が使用され、値を返すと背後にあるプロパティの状態が更新されます。
<?php
declare(strict_types=1);
class CustomerAccount
{
public string $email {
set {
$sanitized = filter_var(trim($value), FILTER_VALIDATE_EMAIL);
if ($sanitized === false) {
throw new InvalidArgumentException("無効なメールアドレスです: {$value}");
}
return strtolower($sanitized);
}
}
// 仮想プロパティ: バッキングストレージを持たず、アクセス時に計算される
public string $displayName {
get => ucfirst(explode('@', $this->email)[0]);
}
public function __construct(string $email)
{
$this->email = $email; // set フックが自動的にトリガーされる
}
}
$account = new CustomerAccount(' [email protected] ');
echo $account->email; // '[email protected]'
echo $account->displayName; // 'Alex.dev'
ステップ 3: フックと非対称な可視性の組み合わせ
両方の機能を組み合わせることで、簡潔で自己防衛的なドメインモデルが完成します。以下は、投票数が外部に公開されつつも、外部の呼び出し元が手動で数値を改ざんしたり減算したりできない投票ウィジェットの例です。
<?php
declare(strict_types=1);
class ProductReview
{
public private(set) int $helpfulVotes = 0 {
set {
if ($value < 0) {
throw new LogicException('投票数を負の値にすることはできません。');
}
return $value;
}
}
public function __construct(public readonly string $content) {}
public function upvote(): void
{
$this->helpfulVotes++;
}
}
$review = new ProductReview('発送が早く、作りもしっかりしています。');
$review->upvote();
echo $review->helpfulVotes; // 1
// $review->helpfulVotes = 10;
// Fatal Error: Cannot modify private(set) property ProductReview::$helpfulVotes from global scope(グローバルスコープから private(set) プロパティ ProductReview::$helpfulVotes を変更することはできません)
PHP 8.4 を活用すれば、何千行ものボイラープレートを書くことなく、スリムで表現力豊か、かつ適切に保護されたエンティティクラスを維持できます。

