PHPのAttributeで宣言的にメタデータを書く

PHPのAttribute(属性)とは、クラスやメソッド、プロパティに#[...]という構文でメタデータを付与し、実行時にReflection経由で取り出せるようにする言語機能です。PHP 8.0から入っていますが、実務で「知ってはいるけど自分で書いたことはない」という人が意外と多い印象です。

ルーティング定義やバリデーションルールなど、フレームワークの内部では当たり前のように使われています。今日はその中身を、自分でAttributeクラスを作るところから見ていきます。

Attributeは何を置き換えたのか

PHP 8以前は、この手のメタデータはdocblockのアノテーションで表現するのが定番でした。Doctrineのアノテーションなどがそうですね。ただしdocblockはただのコメントなので、PHPパーサーは中身を理解しません。専用のパーサーライブラリを噛ませて、コメント文字列を正規表現で読み解く、という力技になっていました。

Attributeはこれを言語の正式な構文にしたものです。#[Route('/users')]のように書けば、PHP自身がこれをASTとして解釈し、Reflectionで型安全に取り出せます。文字列パースの脆さから解放されるのが一番の恩恵だと思います。

自作のAttributeを定義する

Attributeも実体はただのクラスです。#[Attribute]という属性(メタ属性というと紛らわしいですが)を自分のクラスに付けるだけで、Attributeとして使えるようになります。

#[Attribute(Attribute::TARGET_METHOD)]
class Route
{
    public function __construct(
        public readonly string $path,
        public readonly string $method = 'GET',
    ) {
    }
}

Attribute::TARGET_METHODを指定しているのは、このAttributeをメソッド以外(プロパティやクラス)に付けようとしたときにエラーで弾くためです。省略もできますが、意図しない場所への付与を防げるので、実務では明示しておいた方が安全だと思います。

Attributeはどう使う?

定義したRouteを実際のコントローラーに付けてみます。

class UserController
{
    #[Route('/users', method: 'GET')]
    public function index(): array
    {
        return [];
    }

    #[Route('/users', method: 'POST')]
    public function store(): array
    {
        return [];
    }
}

この時点ではまだ何も起きません。Attributeは付けただけでは動作を変えず、誰かがReflectionで読みに行って初めて意味を持ちます。ここが「デコレータっぽいけど実は違う」ところで、最初に誤解しやすいポイントかもしれません。

Reflectionで取り出す

$reflection = new ReflectionClass(UserController::class);

foreach ($reflection->getMethods() as $method) {
    foreach ($method->getAttributes(Route::class) as $attribute) {
        $route = $attribute->newInstance();
        echo sprintf("%s %s\n", $route->method, $route->path);
    }
}

getAttributes()ReflectionAttributeの配列を返すだけで、まだインスタンス化されていません。newInstance()を呼んだ瞬間にコンストラクタが実行され、実体のオブジェクトになります。この2段階になっているおかげで、「属性の存在だけ確認したい」ときはインスタンス化のコストを払わずに済みます。

ハマりどころ:引数はコンパイル時定数しか渡せない

普通のコンストラクタ呼び出しと違い、Attributeの引数には変数や関数呼び出しの結果を渡せません。定数式(リテラル、::class、他の定数)だけがOKです。

// これはエラーになる
$path = '/users';
#[Route($path)]
class Foo {}

// クラス定数ならOK
class Paths
{
    public const USERS = '/users';
}

#[Route(Paths::USERS)]
class Bar {}

これは地味にハマります。設定値を外部から注入したくなる場面があっても、Attributeの定義段階では完結させる必要があるということです。「実行時に決まる値」をAttributeに乗せようとしたら、それはもう設計を見直すサインだと考えた方がいいと思います。

同じAttributeを複数回付けたいとき

デフォルトでは、同一のAttributeを1つの対象に2回以上付けるとエラーになります。複数付けたいなら、定義側で明示的に許可する必要があります。

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
class Tag
{
    public function __construct(public readonly string $name)
    {
    }
}

class Article
{
    #[Tag('php')]
    #[Tag('backend')]
    public string $title = '';
}

IS_REPEATABLEを付け忘れて「なぜかFatal Errorになる」というのは、私も一度やった記憶があります。複数タグを想定するならこのフラグを最初から入れておいた方がいいですね。

実務での使いどころ:属性ベースのバリデーション

DTOのプロパティにルールを書いておいて、Reflectionでまとめて検証する、というのが分かりやすい活用例です。

#[Attribute(Attribute::TARGET_PROPERTY)]
class NotBlank
{
}

#[Attribute(Attribute::TARGET_PROPERTY)]
class MaxLength
{
    public function __construct(public readonly int $max)
    {
    }
}

class UserDto
{
    #[NotBlank]
    #[MaxLength(50)]
    public string $name = '';
}

function validate(object $dto): array
{
    $errors = [];
    $reflection = new ReflectionClass($dto);

    foreach ($reflection->getProperties() as $property) {
        $value = $property->getValue($dto);

        foreach ($property->getAttributes(NotBlank::class) as $attr) {
            if ($value === '') {
                $errors[] = $property->getName() . ' is required';
            }
        }

        foreach ($property->getAttributes(MaxLength::class) as $attr) {
            $rule = $attr->newInstance();
            if (mb_strlen($value) > $rule->max) {
                $errors[] = $property->getName() . ' is too long';
            }
        }
    }

    return $errors;
}

バリデーションロジックとルールの定義が分離されて、DTOを見ただけで「何を守るべきか」が分かるようになります。if文の羅列でルールを書いていくより、宣言的で見通しがいいと個人的には思います。ただしルールの種類が増えてくるとvalidate()側の分岐も膨らむので、ルールごとにインターフェースを切って多態的に処理する、という設計に発展させることが多いです。

Reflectionのコストは無視できるのか

Attributeを使う設計は、リクエストのたびにReflectionでクラスを解析し直すと、地味にオーバーヘッドになります。SymfonyやLaravelのような大きめのフレームワークは、この解析結果をキャッシュしてコンパイル済みのメタデータとして持つことで対処しています。

自作の仕組みでAttributeを本番投入するなら、リクエストごとにReflectionを回すのではなく、起動時や初回アクセス時に一度解析してキャッシュに乗せる、という設計を最初から意識しておいた方がいいと思います。小さなバッチ処理くらいであれば、そこまで神経質にならなくても実用上は困らないかもしれません。

まとめ

Attributeは、docblockアノテーションが担っていた役割を言語の正式な構文として引き継いだ機能です。定義自体はただのクラスなので難しくはありませんが、引数がコンパイル時定数に限られる点や、複数付与にIS_REPEATABLEが要る点は最初につまずきやすいところです。ルーティングやバリデーションのように「宣言と実装を分離したい」場面で使うと、コードの見通しがぐっと良くなる機能だと思います。

よくある質問

Q. Attributeとdocblockアノテーションはどちらを使うべきですか?
A. 新規に書くコードならAttributeを使うべきです。言語がパースしてくれるので、外部ライブラリへの依存やパースの脆さがなくなります。docblockは既存資産の互換性維持くらいの位置づけでいいと思います。

Q. Attributeにデフォルト値を持たせることはできますか?
A. できます。コンストラクタのプロパティプロモーションにデフォルト値を書けば、Attribute側で省略した場合にそれが使われます。この記事のRouteのmethod = 'GET'がその例です。

Q. どのクラスにAttributeが付いているか、実行時に一覧できますか?
A. できます。ただし全クラスを総当たりでReflectionにかけるのは重いので、Composerのオートロード情報やディレクトリ規約と組み合わせて対象クラスを絞り込むのが現実的です。

類似投稿

コメントを残す

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