PHP 8.4のプロパティフックでgetter/setterを捨てる

プロパティフックは、PHP 8.4で追加された、プロパティの読み書きに get / set の処理を割り込ませる仕組みです。getterやsetterのメソッドを書かずに済みます。

「とりあえず private にして getName() と setName() を生やしておく」というコードを、私はどれだけ書いてきたか分かりません。中身はほぼ return $this->name; だけ。IDEが自動生成してくれるとはいえ、クラスの半分が意味のないメソッドで埋まるのは気持ちのいいものではありませんでした。

8.4のプロパティフックは、そこにきれいに刺さる機能です。ただし素直に見える割に、挙動の細かいところで引っかかる点がいくつかあります。今日はそのあたりを中心に。

まずは一番おいしいところ

従来の「trimして格納したいだけ」のsetterは、こう書いていました。

<?php
class Product
{
    private string $name = '';

    public function getName(): string
    {
        return $this->name;
    }

    public function setName(string $value): void
    {
        $this->name = trim($value);
    }
}

これがプロパティフックだと1つのプロパティ宣言に畳めます。set をアロー式で書いた場合、式の評価結果がそのまま backing value(実体の格納領域)に代入されます。

<?php
class Product
{
    public string $name = '' {
        set => trim($value);
    }
}

$p = new Product();
$p->name = '  ノート  ';
echo $p->name; // ノート

呼び出し側は普通のプロパティアクセスのままです。つまり「今は素のプロパティでいいけど、将来バリデーションを足すかもしれない」というときに、公開APIを壊さずに後から差し込めるようになったのが大きいところですね。

「バック付き」と「仮想」はどう違う?

フックには2種類の状態があります。フックの中で $this->プロパティ名 をそのままの書き方で参照していれば「バック付き(backed)」、参照していなければ「仮想(virtual)」です。仮想プロパティは値を保持する領域を持たず、オブジェクトのメモリを消費しません。

<?php
class Order
{
    // 仮想プロパティ。実体を持たない
    public int $subtotal {
        get => $this->unitPrice * $this->quantity;
    }

    public function __construct(
        public int $unitPrice,
        public int $quantity,
    ) {}
}

$o = new Order(1200, 3);
echo $o->subtotal; // 3600
$o->subtotal = 100; // Error: set フックが無いので書き込めない

派生値をプロパティとして見せられるのが嬉しいところです。getSubtotal() というメソッドを生やすのと機能は同じですが、呼び出し側から見て「合計は Order の属性である」と読めるようになります。

なお、仮想プロパティに初期値は書けません。値を置く場所がないので当然といえば当然ですが、backing value を参照しているつもりで $this->$temp のような可変プロパティ経由で書くと、エンジンからは「参照していない」と判定されて仮想扱いになり、エラーになります。ここは字面での判定です。

setフックの型は広げられる

set の引数型は省略できて、その場合はプロパティと同じ型で $value という名前になります。明示する場合、プロパティの型と同じか、それより広い型(反変)にする必要があります。string のプロパティに string|Stringable を受ける set は書けますが、array だけを受ける set は書けません。

<?php
class Article
{
    public DateTimeImmutable $publishedAt {
        set(string|DateTimeImmutable $value) {
            $this->publishedAt = is_string($value)
                ? new DateTimeImmutable($value)
                : $value;
        }
    }
}

$a = new Article();
$a->publishedAt = '2026-08-06';
echo $a->publishedAt->format('Y-m-d'); // 2026-08-06

「文字列でもオブジェクトでも受けて、内部では必ずオブジェクトで持つ」という正規化を、代入の位置で完結できます。個人的にはこれが実務で一番使う形になりそうだと思っています。

なぜreadonlyと併用できないのか

これは覚えておくべき制約です。プロパティフックは readonly と併用できません。readonly は「初期化後は書き込みを一切通さない」保証で、set フックは「書き込みを通して加工する」ものなので、素直に噛み合わないわけですね。

公式マニュアルも、読み書きのアクセス制限とフックを両立したいなら非対称可視性(asymmetric visibility)を使え、と案内しています。こちらは 8.4 で同時に入った機能です。

<?php
class Money
{
    // 読みは public、書きは private
    public private(set) int $amount {
        set => max(0, $value);
    }

    public function __construct(int $amount)
    {
        $this->amount = $amount;
    }
}

$m = new Money(-50);
echo $m->amount; // 0
$m->amount = 100; // Error: 外部からは書き込めない

readonly ほど厳密ではありませんが(クラス内部からは何度でも書ける)、「外に対しては読み取り専用、内部の書き込みは正規化を通す」という現実的な落としどころとしては、こちらのほうが使いやすい場面が多い気がしています。

配列プロパティが一番ハマる

ここが実務で確実に踏むポイントです。フックが付いたプロパティは、読み書きを常にフック経由で行うため、参照の取得や「間接的な変更」と相性が悪い。配列の要素への代入は内部的に参照を必要とするので、これが引っかかります。

<?php
class Bag
{
    public array $items = [] {
        get => $this->items;
    }
}

$bag = new Bag();
$bag->items = ['a' => 1]; // OK。丸ごと代入は普通に通る
$bag->items['b'] = 2;     // Error。間接的な変更は許されない

バック付きの配列プロパティで要素書き換えを許すには、&get(参照返しの get)だけを定義する必要があります。「だけ」というのが重要で、バック付きプロパティに &getset を両方定義することはできません。参照経由の書き込みが set フックを素通りしてしまうからです。

<?php
class Bag
{
    public array $items = [] {
        &get => $this->items;
    }
}

$bag = new Bag();
$bag->items['b'] = 2; // OK

ちなみに get&get を同じプロパティに両方書くのは構文エラーです。配列を持つプロパティにフックを付けたくなったら、まず「本当にプロパティとして公開する必要があるか」を疑ったほうがいいかもしれません。add() のようなメソッドを用意したほうが素直に済むことが多いです。

コンストラクタプロモーションの型は広がらない

フックはコンストラクタプロモーションと一緒に使えます。ただし、コンストラクタの引数型として使われるのは プロパティに宣言した型 であって、set フックが受け付ける広い型ではありません。

<?php
class Example
{
    public function __construct(
        public private(set) DateTimeInterface $created {
            set (string|DateTimeInterface $value) {
                if (is_string($value)) {
                    $value = new DateTimeImmutable($value);
                }
                $this->created = $value;
            }
        },
    ) {}
}

// コンストラクタは DateTimeInterface しか受け付けない
$e = new Example(new DateTimeImmutable('2026-08-06'));

// 生成後の代入なら文字列でも通る
// $e->created = '2026-08-07'; // ただし private(set) なので外部からは不可

「フックを書いたのだからコンストラクタにも文字列を渡せるだろう」と思い込むと、そこで TypeError を食らいます。コンストラクタでも広い型を受けたいなら、プロモーションを諦めて普通にプロパティ宣言と __construct() を分けて書く必要があります。

var_dumpとjson_encodeで結果が違う

シリアライズ系の関数が、フックを通すか生の値を見るかは関数ごとに違います。これは知らないと確実に混乱するところです。

生の backing value を見るのは var_dump()serialize()unserialize()、配列キャスト、get_mangled_object_vars()。get フックを通すのは var_export()json_encode()get_object_vars()__serialize() / __unserialize()JsonSerializable は自前のロジック次第で、その中の読み書きはフックを通ります。

つまり var_dump() でデバッグしていると仮想プロパティは見えず、加工前の値が出てきます。一方 API レスポンスを作る json_encode() では加工後の値が出る。この非対称は仕様なので、デバッグ時に「値が違う」と焦らないようにしたいところです。

インターフェースにプロパティを書けるようになった

8.4 からはインターフェースがプロパティを宣言できます。読めるのか、書けるのか、両方かを { get; } / { set; } / { get; set; } で指定します。この宣言が効くのは public な読み書きに対してだけです。

<?php
interface HasSlug
{
    public string $slug { get; }
}

class Page implements HasSlug
{
    public function __construct(public string $title) {}

    // 仮想プロパティで get だけ実装すれば契約を満たす
    public string $slug {
        get => strtolower(str_replace(' ', '-', $this->title));
    }
}

実装側は素のプロパティでも、仮想プロパティでも、get だけの要求なら readonly プロパティでも満たせます。ただし set を要求されているプロパティを readonly で満たすことはできません。

親のフックはparent::$prop::get()で呼ぶ

子クラスでフックを追加・上書きするときは、親のフックが自動で呼ばれたりはしません。明示的に呼びます。

<?php
class Point
{
    public int $x = 0;
}

class PositivePoint extends Point
{
    // 子でフックを足すと親の初期値は消えるので書き直す
    public int $x = 0 {
        set {
            if ($value < 0) {
                throw new InvalidArgumentException('負の値は不可');
            }
            parent::$x::set($value);
        }
    }
}

直接 $this->x = $value; と書いても動きますが、その場合は親が将来 set フックを追加しても無視されます。parent::$x::set() を通しておけば、親側の変更が効くようになります。メソッドで parent::method() を呼ぶのと同じ発想ですね。

コメントにも書きましたが、子クラスでフックを追加すると親で設定していたデフォルト値は消えます。これは静かに挙動が変わるので、地味に嫌なハマりどころです。

まとめ

プロパティフックは、getter/setter の定型文を消すだけでなく、「素のプロパティで公開しておいて、必要になったら後から加工を差し込む」という選択肢を作ってくれました。値オブジェクトや DTO を書く量が目に見えて減ります。

一方で、readonly と併用できない、配列の要素書き換えは &get が要る、コンストラクタプロモーションでは型が広がらない、デバッグ関数によって見える値が違う。この4つは知らないと必ず一度は詰まる箇所だと思います。特に配列まわりは、フックを付けた瞬間に既存コードが動かなくなるので、既存クラスに後付けするときは注意したほうがいいかもしれません。

私はしばらく、新規に書く値オブジェクトから少しずつ置き換えていくつもりです。全部を一気に書き換える種類の機能ではない、という気がしています。

よくある質問

Q. プロパティフックはPHPのどのバージョンから使えますか?
A. PHP 8.4 で導入されました。8.3以前では構文エラーになるので、動作環境のバージョンを確認してから使ってください。

Q. readonly プロパティにフックを付けられますか?
A. できません。プロパティフックと readonly は併用不可です。読み取り専用に見せつつ加工したい場合は、非対称可視性(public private(set) など)を使うのが公式に案内されている方法です。

Q. __get / __set とはどう使い分けますか?
A. プロパティフックは「宣言済みのプロパティ」に対する仕組みで、対象が静的に決まるためIDEや静的解析が理解できます。__get / __set は未定義のプロパティへのアクセスを拾うもので、用途が違います。名前が分かっているプロパティならフック、動的なキーを扱うならマジックメソッド、という分け方になります。

Q. 仮想プロパティに初期値を設定できますか?
A. できません。仮想プロパティは値を格納する領域を持たないためです。初期値が必要なら、バック付きプロパティにするか、別のプロパティに値を持たせて get で組み立ててください。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です