1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

API Platform ベストプラクティス — 実戦投入で見えてきたこと

1
Last updated at Posted at 2026-05-29

という記事を、経験を積んだ Claude Code に書かせてみました。実務の経験が反映されてることと、人間がすごい監修入れてるところがミソです。

以下コピペ:


API Platform は「エンティティに #[ApiResource] をつければ即 REST」というお手軽さで有名ですが、それを本気で受け取ると数ヶ月後に痛い目を見ます。
本稿は、複数カーネルが同居する Symfony プロジェクトで API Platform を運用してきて固まってきた指針をまとめたものです。フレームワークの「できる」と、現場の「やるべき」のあいだに線を引くのが目的です。


1. Resource と Entity は別物として扱う

公式チュートリアルは Doctrine エンティティに直接 #[ApiResource] を載せます。動きますが、これは 永続化の都合API の契約を同じクラスに同居させる行為で、両者の寿命がズレ始めた瞬間に詰みます。

ルール:

  • DTO(*Resource.php)だけが API に出る形。
  • エンティティ(src/Entity/)は永続化専用。#[ApiResource]#[ApiProperty]#[Assert\*]#[UniqueEntity] も載せない。
  • 変換は Provider/Processor で 明示的に マッピングする。エンティティを API Platform に素通しさせない。
  • バリデーションは「入力が入ってくる境界」に置く: API なら DTO、CLI なら ValidatorInterface を呼ぶコマンド本体。

エンティティ側はアノテーションを極力削ぎ落とし、Doctrine の #[ORM\*] だけ残します。title のような業務プロパティも、長さや必須性の制約をここには書きません:

#[ORM\Entity(repositoryClass: TaskRepository::class)]
class Task
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    public private(set) ?int $id = null;

    #[ORM\Column(length: 255)]
    public string $title;

    #[ORM\Column]
    public DateTimeImmutable $createdAt;
}

これに対する API 側の DTO はこうなります。#[ApiResource] を持つのはこちらだけ、title#[Assert\*] 制約もこちらに集約します:

#[ApiResource(/* operations, security, processor, provider など */)]
final class TaskResource
{
    #[ApiProperty(writable: false, identifier: true)]
    public int $id;

    #[Assert\NotBlank]
    #[Assert\Length(max: 255)]
    public string $title;

    #[ApiProperty(writable: false)]
    public DateTimeImmutable $createdAt;

    public static function fromEntity(Task $entity): self
    {
        \assert($entity->id !== null);

        $r = new self();
        $r->id = $entity->id;
        $r->title = $entity->title;
        $r->createdAt = $entity->createdAt;
        return $r;
    }
}

こうしておくと、CLI からタスクを作るコマンドは「エンティティを new して ValidatorInterface でその場でチェックする」のではなく、「同じ Assert 制約が乗った DTO(あるいはコマンド用の入力 DTO)を経由する」設計に統一できます。バリデーションは入り口に置くを貫けば、エンティティはいつでも「すでに正しい値しか入っていない」状態に保てます。

fromEntity の冒頭にある \assert($entity->id !== null) は、永続化前のエンティティでは $idnull のままだからです。レスポンス DTO は「すでに保存されたもの」を表すので、まだ id が無いエンティティを fromEntity に渡しているなら呼び出し側の使い方が間違っている、という契約をここで明示します。


2. Provider と Processor の責務を分ける

エンティティに直接 #[ApiResource] を載せていたときは、API Platform が Provider で引いたエンティティをそのまま Processor まで持っていって くれていました。PATCH なら「行を引く → 部分更新を適用する → flush」の流れがフレームワーク内で完結し、こちら側で同じ行をもう一度引きにいく必要はありません。

ここでエンティティと DTO を分離すると、その自動バトンが切れます。素直に書くと、

  • Provider は URI から DTO を組み立てて 返す(エンティティはここで一度引いている)
  • Processor は DTO を受け取り、PATCH を適用したいので 対象エンティティをリポジトリからもう一度引く

という二度引きが発生する。デグレです。

二度引きの何が嫌かというと、Processor が「対象が見つからない」「対象を見る権限がない」という異常系を抱え込む ことです。Provider 側で既に同じガードをしているのに、Processor 側でも同じ throw を @throws に書き、ユニットテストで再度検証することになる:

final class TaskItemProvider implements ProviderInterface
{
    public function __construct(
        private TaskRepository $tasks,
        private ProjectService $projects,
    ) {}

    /**
     * @throws NotFoundHttpException        URI のタスクが存在しない
     * @throws AccessDeniedHttpException    閲覧権限がない
     */
    public function provide(/* ... */): TaskResource { /* ... */ }
}

final class TaskPatchProcessor implements ProcessorInterface
{
    public function __construct(
        private TaskRepository $tasks,          // ← Provider と同じ依存
        private ProjectService $projects,       // ← Provider と同じ依存
    ) {}

    /**
     * @throws NotFoundHttpException        Provider が既に弾いているので「実際には起きない」
     * @throws AccessDeniedHttpException    同上
     * @throws ConflictHttpException        本来 Processor がガードすべき本物の異常系
     */
    public function process(/* ... */): TaskResource { /* ... */ }
}

@throws がテストの目録だ(セクション 8 で詳しく)という運用にしている以上、実際には起きないパスのために unit テストを書く ことになります。テストが増えるだけならまだしも、その手のテストはモックが噛み合った瞬間に「コードが意図通りに動いているか」ではなく「モックが意図通りにモックしているか」を確かめる作業に変わり、リファクタの足を引っ張ります。

これを避けるために、DTO に「もとになったエンティティ」をしのばせて Processor まで持ち運ぶ パターンを使います。API に出す形と、内部の運搬役を、同じクラスで兼任させるイメージです。

final class TaskResource
{
    // OpenAPI から隠した「運搬スロット」
    #[ApiProperty(readable: false, writable: false)]
    private ?Task $entity = null;

    // ... 公開フィールド ...

    public static function fromEntity(Task $entity): self
    {
        \assert($entity->id !== null);

        $r = new self();
        $r->entity = $entity;
        $r->id = $entity->id;
        $r->title = $entity->title;
        $r->createdAt = $entity->createdAt;
        return $r;
    }

    /** @throws LogicException Provider 経由でない(エンティティ未設定の)Resource で呼んだ場合 */
    public function entity(): Task
    {
        return $this->entity ?? throw new LogicException('Resource has no backing entity');
    }
}

実装パターン:

  • Resource に private ?Entity $entity = null#[ApiProperty(readable: false, writable: false)] でしのばせ、OpenAPI から隠す。
  • fromEntity() がエンティティを差し込み、entity() アクセサが取り出す(行がない場合は LogicException)。
  • Provider が URI → エンティティの仕事をする: リポジトリ参照、NotFoundHttpException、可視性ベースの認可(assertVisible 系)はここ。Item Provider が ?Resource を返せば API Platform が 404 を組み立ててくれる。
  • Processor は本文と書き込み時の関心事に集中する: 書き込み権限チェック(assertOwner)、業務上の不変条件、本文由来の参照(リクエストボディ内 IRI → エンティティ)はここ。$data->entity() を呼べばリポジトリを再度叩かなくて済む。

結果として Processor からリポジトリ依存と「ありえない 404」が落ちます:

final class TaskPatchProcessor implements ProcessorInterface
{
    public function __construct(
        private EntityManagerInterface $em,
        private ProjectService $projects,
    ) {}

    /**
     * @throws AccessDeniedHttpException    書き込み権限がない(Provider の閲覧可視性とは別レイヤ)
     * @throws ConflictHttpException        楽観ロック衝突などの書き込み時不変条件違反
     */
    public function process(TaskResource $data, /* ... */): TaskResource
    {
        $task = $data->entity();
        // ... patch を $task に反映 ...
    }
}

TaskRepository がコンストラクタから消え、@throws から NotFoundHttpException が消え、Processor の unit テストは「書き込み時にしか起きえない異常系」だけを面倒見ればよくなります。Provider の unit テストと役割がきれいに分かれるので、テスト同士の重複も解消します。


3. URI に親を含む POST も同じパターンで書ける

POST /projects/{projectName}/tasks のように親リソースを URI に含む作成系は、まだ存在しない行を作る操作なので Provider を持たないと思い込みがちですが、同じパターンが使えます。

ポイントは、Post(read: true) で Provider を発火させて、親エンティティをしのばせた空の DTO を返すこと。リクエストボディはその空 DTO の上にマージされて Processor に届きます。

#[ApiResource(
    uriTemplate: '/projects/{projectName}/tasks',
    uriVariables: [
        'projectName' => new Link(fromClass: ProjectResource::class, fromProperty: 'name'),
    ],
    operations: [
        new Post(
            read: true,                              // ← 通常 POST では false。Provider を呼ばせるために有効化
            provider: TaskPostProvider::class,
            processor: TaskPostProcessor::class,
        ),
        // ... 他のオペレーション ...
    ],
)]
final class TaskResource { /* ... */ }

Provider はこんな形になります。返ってくる DTO の project フィールドにだけエンティティがしのばされている のがミソです:

final class TaskPostProvider implements ProviderInterface
{
    // コンストラクタ省略(ProjectRepository を注入)

    /**
     * @throws NotFoundHttpException URI の projectName に対応するプロジェクトが存在しない
     */
    public function provide(Operation $operation, array $uriVariables = [], array $context = []): TaskResource
    {
        $project = $this->projects->findOneBy(['name' => $uriVariables['projectName']])
            ?? throw new NotFoundHttpException();

        $r = new TaskResource();
        $r->project = ProjectResource::fromEntity($project);  // ← ここだけエンティティ入りの DTO
        return $r;
    }
}

$r 自体は空の TaskResource ですが、$r->projectProjectResource::fromEntity() で作られたエンティティ入りの DTO です。リクエストボディの title などはこの $r の上にマージされて Processor に届くので、Processor は $data->project->entity() で親プロジェクトをそのまま取り出せます:

final class TaskPostProcessor implements ProcessorInterface
{
    // コンストラクタ省略(EntityManagerInterface を注入)

    public function process(TaskResource $data, /* ... */): TaskResource
    {
        $project = $data->project->entity();   // ← Provider がしのばせた親エンティティ

        $task = new Task();
        $task->project = $project;
        // ... $data の他フィールドを $task に反映、persist & flush、fromEntity で返す ...
    }
}

4. 入力 DTO はなるべく Resource を使い回す

API Platform の DTO は基本「出力用」として設計しがちですが、#[ApiProperty]writable / readable フラグを正しく使えば 同じ Resource を GET の応答にも PATCH の入力にも使い回せる ことが多い、というのが普段の運用ベースです。

仕組み: writable: false / readable: false はシリアライザのオン/オフ

#[ApiProperty] の2つのフラグは、API Platform のシリアライザがそのプロパティをどっち向きに扱うかを直接制御します:

  • writable: falseデノーマライザがスキップ。クライアントが送ってきた JSON にそのキーがあっても、リソースには反映されない(PropertyAccessor が落ちて 500 になることもない)。
  • readable: falseノーマライザがスキップ。レスポンス JSON にそのキーは含まれない(セクション 2 で出てきた「DTO にエンティティをしのばせる」スロットは、これで OpenAPI からも消える)。

DTO のプロパティはすべて public のままで構いません。アクセス制御は PHP 可視性ではなく、#[ApiProperty] フラグで「シリアライザの目線で何が起きるか」だけを宣言します。OpenAPI スキーマも SchemaPropertyMetadataFactory がこのフラグを見て、入力スキーマと出力スキーマを自動的に出し分けます。

1つの Resource で GET も PATCH も賄う

id / createdAt のような「サーバ採番」「サーバ生成」のフィールドを writable: false にしておけば、同じ Resource クラスを GET の応答にも PATCH の入力にも使い回せます。クライアントが間違って {"id": 99} を送ってきても黙って無視されるので、安全:

final class TaskResource
{
    #[ApiProperty(writable: false, identifier: true)]
    public int $id;                              // GET で返るが PATCH では無視される

    #[Assert\NotBlank]
    #[Assert\Length(max: 255)]
    public string $title;                        // GET でも PATCH でも有効

    #[ApiProperty(writable: false)]
    public DateTimeImmutable $createdAt;         // GET で返るが PATCH では無視される
}

PATCH オペレーションはこの Resource をそのまま input: として受け取れます。Resource を2つに分ける必要はありません。

readable: false は「書けるが見せられない」フィールドのためにある

writable: false の対称版である readable: false も、共有 Resource の中でたまに欲しくなります。典型はユーザー自身のパスワード再設定のような PATCH で受け付けたいが GET レスポンスには絶対出してはいけない秘匿フィールド:

final class UserResource
{
    public string $email;                                // GET でも PATCH でも有効

    #[ApiProperty(readable: false)]
    #[Assert\Length(min: 12)]
    public ?string $newPassword = null;                  // PATCH で受け取るが GET には出ない
}

readable: false を付けると ノーマライザがこのフィールドを GET レスポンスから外し、入力スキーマには残ったまま受け付けられる状態になります。「書けるが見せられない」が単一の DTO 上に共存できる、というわけです。普段あちこちで使うフラグではなく、こうした秘匿フィールド (セクション 2 のエンティティ運搬スロットを別とすれば) でだけ出番がある、と覚えておくと迷いません。

細かな注意

  • protected(set) を Resource に使わない。PHP レベルの書き込み保護と OpenAPI のドキュメンテーションを混ぜることになり、Resource は本来「テストファクトリや Provider/Processor から自由に組み立てられる出力 DTO」なのでこの保護自体が邪魔。protected(set) はエンティティ専用、つまり Doctrine 管理の本物の不変条件にとっておく。
  • #[ApiProperty(openapiContext: ['readOnly' => true])] も書かない。writable: false だけで OpenAPI のフラグまで面倒を見てくれる。

5. POST 用の DTO は別物が要ることが多い

セクション 4 の「GET と PATCH を 1 つの Resource で兼任させる」 はうまくいきますが、POST に同じ DTO を流用しようとすると、たちまち破綻します。よくあるパターン:

  • POST 限定の必須項目 がある(一度作ったら変えられない slug、初回しか受け付けない招待トークンなど)。PATCH では writable: false、POST では writable: true というオペレーション別の出し分けは #[ApiProperty] だけでは綺麗に書けない。
  • POST だけ受け付けたい補助フィールド がある(パスワード平文、確認用パスワード、初期パスワードと招待メール送信フラグなど)。これらは GET でも PATCH でも出ては困るが、readable: false + writable: true だと「ふだん隠れているが書ける」という曖昧な存在になり、PATCH からも値を投げ込めてしまう。
  • 入力と出力でキー名やネスト構造が変わる: POST 時は親リソースの ID で受けて、レスポンスでは展開済みオブジェクトを返す、など。
  • 必須/任意の関係が逆転する: GET / PATCH では nullable な description も、POST では必須にしたい、など。

これらは「プロパティ単位の読み書きオン/オフ」では表現できません。POST 専用の入力 DTO を別クラスとして立て、input: で指定するほうが結局読みやすくなります:

#[ApiResource(
    operations: [
        new Post(
            input: TaskPostInput::class,         // ← POST だけ別形の DTO を受け付ける
            provider: TaskPostProvider::class,
            processor: TaskPostProcessor::class,
        ),
        new Get(),
        new Patch(),                             // ← input を指定しなければ TaskResource を使う
    ],
)]
final class TaskResource { /* ... */ }

Processor で TaskPostInput → Task のマッピングを書き、レスポンスは TaskResource::fromEntity($task) で返します。POST のリクエスト形と GET/PATCH の表現が別物として共存する形です。

判断基準としては:

  • 最初は GET / PATCH / POST すべてを 1 つの Resource で書き始めて構わない。writable: false だけで保護できるなら、わざわざ DTO を分ける必要はない。
  • 特殊化が必要になったら、まず POST を疑う。上に挙げたような分岐は POST 側に偏って現れることがほとんどで、PATCH を別 DTO にしないと収まらないケースは実際にはあまり出てこない。

6. PHP の null と JSON の undefined の対応を意識する

PHP に「未定義」という概念はなく、初期化されていないプロパティを読むとエラーになります。一方 JSON / JavaScript は nullundefined(キーが存在しない)が別物として共存します。API Platform はこの差を埋めるために、シリアライザを「nullundefined 扱いする」設定で動かしますskip_null_values です。

これを前提に、出力・入力・PATCH の挙動を整理しておきます。

出力: ?T で値が null のフィールドはレスポンスから消える

skip_null_values が有効なので、?T 型のプロパティに実値の null が入っていても、レスポンス JSON にはそのキーごと現れません。

final class TaskResource
{
    #[ApiProperty(writable: false)]
    public ?DateTimeImmutable $completedAt = null;   // 未完了タスクではキーごと消える
}

未完了なら completedAt キーが無い、完了済みなら ISO8601 文字列が入る — クライアントから見ると「undefined(キー無し)か文字列」という素直な型になります。OpenAPI スキーマもこの挙動と整合させたいので、出力側の型ユニオンからは null を落としておくのが正直です(実際 wire には null が乗らないので、T | null ではなく T)。

入力: クライアントが「設定しない」を表現したいときはキーを送らない

逆向きにも同じルールが効きます。クライアントがあるフィールドを変更したくないとき、{"foo": null} を送るのではなく キー自体を含めない のが正しい作法です:

たとえば description を触らずに title だけ更新したいなら、リクエストボディは:

{ "title": "新しいタイトル" }

の1キーだけ。description: null を付け足したりはしません。PHP デフォルトを = null にしておけば、入力 DTO 側でも「リクエストボディにキーが無い」という状況を素直に受け入れられます。

PATCH: 「未定義 = 無視、明示的 null = 削除」という mergePatchJson 仕様

API Platform の PATCH は application/merge-patch+json (RFC 7396) のセマンティクスを採用しています。これは skip_null_values の例外ルールで、

  • キーがリクエストボディに無い → そのフィールドは変更しない(無視)。
  • キーがあって値が null → そのフィールドを null にクリアする(削除)。

の二段構えで動きます。GET レスポンスでは null が一律 undefined 化されるのに対し、PATCH リクエストでは null に「削除する」という積極的な意味があるわけです。

たとえば description を明示的に空にしたいなら:

{ "description": null }

description には手を付けずに title だけ更新したいなら:

{ "title": "新しいタイトル" }

この対称性をクライアントに正しく伝えるには、OpenAPI スキーマで PATCH 入力プロパティの型を T | null にしておく必要があります — GET の出力スキーマでは null を落としていいのとは扱いが逆になる。共有 Resource を PATCH の入力にも使い回している場合、入力スキーマと出力スキーマで null の出し方が変わるよう、#[ApiProperty] の設定か OpenAPI ドキュメントを整形するデコレータでこの差を作り込みます。

まとめ

  • GET レスポンス: PHP ?T の値が null → wire ではキーごと省略(クライアント側は undefined として観測)。
  • POST / PATCH 入力でキーが無い: 「そのフィールドは触らない」という意図。wire ではキーごと省略。
  • PATCH 入力で明示的に null: 「そのフィールドをクリアする」という意図。wire では "key": null を送る。

null を送るか送らないか」がクライアント側の意図と結びついているのは PATCH のときだけ、と覚えておくと迷いません。


7. 関連リソースは適切に埋め込みで扱う(読み書きともに)

タスクの「割り当てユーザー」のような関連先を API でやり取りするとき、選択肢は2つあります:

  1. IRI 参照: "assignee": "/users/42" のように URL 文字列で参照する。API Platform のデフォルト。
  2. 構造として埋め込み: "assignee": {"id": 42, "username": "alice", ...} のように関連先の「要約」を直接ネストする。

IRI 参照が常に不便なわけではありません。クライアントが関連先の詳細を必要としていない単発の操作なら、文字列1つで済む IRI は十分簡潔です。問題は コレクション です。GET /tasks?perPage=50 の各要素が assignee: "/users/N" を返してきたら、クライアントは画面に名前を出すために 50 回ぶん GET /users/N を追いかけることになる。古典的な N+1 です。1リクエストで済ませるためには、関連先の表示に必要な最小限のフィールドを サーバ側でまとめて埋め込んでしまう 設計が現実解になります。

そこで UserSummary のような 読み取り向けの要約 DTO を別に作って、関連プロパティに埋め込みます:

final class TaskResource
{
    /**
     * Assignee. 出力では `{id, username, displayName}` を含む。
     * PATCH では `id` のみがルックアップに使われ、他のフィールドは無視される。
     * `null` を送ると割り当てを解除する。
     */
    #[Assert\Valid]
    public ?UserSummary $assignee = null;

    // ... 他のフィールド ...
}

要約 DTO 側の #[ApiProperty] はこうなります。id だけが書き込み可、他は writable: false が肝です:

final class UserSummary
{
    #[ApiProperty(identifier: true)]
    public int $id;                      // ルックアップキー。入力でも出力でも使う

    #[ApiProperty(writable: false)]
    public string $username;             // 出力専用

    #[ApiProperty(writable: false)]
    public string $displayName;          // 出力専用

    public static function fromEntity(User $entity): self
    {
        $r = new self();
        $r->id = $entity->id;
        $r->username = $entity->username;
        $r->displayName = $entity->displayName;
        return $r;
    }
}

writable: false のおかげで、クライアントが {"id": 99, "username": "hacker"} を投げてきても username のほうは デノーマライザに無視されます (セクション 4 と同じ仕組み)。id だけが入力経路を通って Processor に届くので、「id でルックアップして親のフィールドを書き換える」という処理が安全に成立します。

OpenAPI スキーマ上も username / displayName は入力スキーマから消え、id だけが入力可能なフィールドとして残ります。生成されるクライアント型を見て「username も送れるんだろう」と勘違いされる余地が無くなる、というおまけ付き。

出力: 関連先の最小限の属性を持った構造体が返る

GET レスポンスはこうなります:

{
  "id": 1,
  "title": "Buy milk",
  "assignee": { "id": 42, "username": "alice", "displayName": "Alice" }
}

クライアントは task.assignee.displayName のような自然なドット記法で関連先のフィールドにアクセスできます。IRI を別途展開しなおす必要はありません。

書き込み: 同じ構造を受けて id だけを採用する

ここで誘惑されがちなのが、入力用に assigneeId: int のような 変則的な書き込み専用プロパティ を追加することです。やめましょう。出力と入力で別のキー名を使うと、

  • TypeScript / クライアント側の型が出力用と入力用で別物になる
  • GET → 編集 → PATCH のラウンドトリップで、フィールド名の付け替えが必要になる
  • *Id 系の write-only プロパティが DTO に増殖する

代わりに、出力と同じ UserSummary 構造をそのまま受け取って、Processor 側で id だけ採用する 設計にします。クライアントは GET で取った構造をそのまま流し込めて、

{ "assignee": { "id": 99 } }

を送れば割り当てが付け替わります。usernamedisplayName がボディに含まれていても無視されます。割り当て解除は null:

{ "assignee": null }

Processor 側はこのインプットを id だけ拾ってルックアップする形になります:

public function process(TaskResource $data, /* ... */): TaskResource
{
    $task = $data->entity();

    if ($data->assignee === null) {
        $task->assignee = null;                                    // 割り当て解除
    } else {
        $task->assignee = $this->userRepository->find($data->assignee->id)
            ?? throw new UnprocessableEntityHttpException('assignee not found');
    }

    // ... flush ...
}

id 以外のフィールドはサーバ側で無視する」というのは docblock に書いておきます (セクション 11 参照)。クライアント側のコードを読む人が「username を変えて送ったらユーザー名が書き換わる?」と勘違いするのを防ぐためです。

このパターンの嬉しさ

  • 入出力の型が対称: クライアントは GET の assignee オブジェクトをそのまま PATCH に流し込める。「assigneeId だけ抜き出して送る」みたいな変換ステップが要らない。
  • TypeScript 型が綺麗: 共有 DTO 1 つで読み書き両方を表現できる。*Id という写像が型システムから消える。
  • PATCH の merge-patch+json と整合: null を送ると関連を切る、というのは セクション 6 で見た「明示的 null = クリア」のセマンティクスと一致する。

埋め込み要約の深さの目安

埋め込む構造は 「クライアントが画面に出すために最低限必要なフィールド」だけ に絞ります。UserSummary は典型的に {id, username, displayName} 程度。フル UserResource を埋め込むと、

  • レスポンスのペイロードが膨らむ
  • 関連先の関連先まで連鎖し、N+1 の温床になる
  • 関連先のフィールドが変わるたびに、それを持つ親リソース全てに影響する

ので避けます。詳細データが要るなら、クライアントが id(あるいは IRI)で別途 GET するのが API らしい分担です。


8. @throws はテストの目録である

process / provide@throws 句は単なるドキュメンタリではありません。そのメソッドのユニットテストで網羅すべきケース一覧として扱います。

  • NotFoundHttpExceptionAccessDeniedExceptionConflictHttpExceptionUnprocessableEntityHttpException、それと exceptionToStatus でマップされるドメイン例外(セクション 9 で詳しく)— ぜんぶ書く。
  • @throws にあるものは、対応するテストケースが必ず必要。
  • @throws にないものは、そのテストを書いてはいけない。書いてしまうと、コードからは決して到達できないパス(たとえば security: "is_granted('ROLE_USER')" で匿名アクセスを既に弾いているオペレーションでの UnauthorizedHttpException)を検証する不毛なテストになる。

たとえばタスクの Item Provider はこういう docblock を持ちます:

final class TaskItemProvider implements ProviderInterface
{
    /**
     * @throws NotFoundHttpException     URI のタスクが存在しない
     * @throws AccessDeniedException     そのタスクを閲覧する権限がない
     */
    public function provide(Operation $operation, array $uriVariables = [], array $context = []): TaskResource
    {
        // ... lookup → 可視性チェック → fromEntity ...
    }
}

この docblock がそのままテストクラスの目録になります。@throws の数 = テストメソッドの数(プラス happy path 1本) という対応関係です:

#[CoversClass(TaskItemProvider::class)]
final class TaskItemProviderTest extends TestCase
{
    public function testProvide(): void
    {
        // happy path: 存在する & 閲覧権限あり → TaskResource が返る
    }

    public function testProvideNotFound(): void
    {
        $this->expectException(NotFoundHttpException::class);
        // URI のタスクが存在しない状況を組み立てて provide() を呼ぶ
    }

    public function testProvideAccessDenied(): void
    {
        $this->expectException(AccessDeniedException::class);
        // 存在はするが閲覧権限がない状況を組み立てて provide() を呼ぶ
    }
}

@throws を増やすときは対応する testProvideXxx を必ず足す、@throws を減らすときは対応するテストを必ず消す。逆に言えば、テストメソッドを書く前に「これは @throws に書けるケースか?」を自問するクセが付きます。コードからは決して到達できないパスにテストを書いてしまう という典型的な無駄を防げます。

プログラミング上のバグを意味する例外(LogicException など、契約違反のシグナル)は載せません。あれは「実行時に起こりうるケース」ではなく「呼び方が間違っている」というメタなシグナルなので、テストの対象外です。


9. ドメイン例外を HTTP ステータスにマップする

NotFoundHttpException のような Symfony 側の HTTP 例外を直接 throw すれば API Platform はそのままステータスコードに変換してくれますが、ドメイン層の例外を Processor の中で HTTP 例外に翻訳するのは関心の混線 です。Processor は HTTP のことを知らなくていいし、ドメイン例外を直接 throw できたほうがテストもシンプルになります。

API Platform はこのために exceptionToStatus という設定を持っていて、ドメイン例外クラス → HTTP ステータスコード のマッピングをリソース/オペレーション単位で宣言できます:

#[ApiResource(
    exceptionToStatus: [
        InvalidProjectMembershipException::class => Response::HTTP_UNPROCESSABLE_ENTITY,
        ConflictingUpdateException::class        => Response::HTTP_CONFLICT,
    ],
    operations: [/* ... */],
)]
final class TaskResource { /* ... */ }

これを宣言しておけば、Processor や Processor が呼び出すドメインサービスは

throw new InvalidProjectMembershipException('...');

とだけ書けば API Platform 側が 422 に翻訳してくれます。@throws には HTTP 例外ではなく ドメイン例外そのもの を書く形になり、unit テストも $this->expectException(InvalidProjectMembershipException::class) で素直に検証できます。HTTP 側のコードが Processor に染み出さないので、ドメインモデルを別文脈(CLI、バックグラウンドジョブ)で再利用するときもそのまま動きます。


10. テストのレイヤを意識する

自動テストは「extends する基底クラス」で3層に名前を切ります。何を継承しているかが、何を確かめているかと完全に対応する形にしておくと、テストの置き場所と粒度がブレません。

  • 純粋な単体テスト — 親: PHPUnit\Framework\TestCase。1本あたりマイクロ秒オーダー。カーネルブート無し、DI コンテナも DB も触らない。依存はモックで差し込む。
  • サービスのテスト — 親: Symfony\Bundle\FrameworkBundle\Test\KernelTestCase。カーネル1回ブート + DB は実物。コンテナから本物のサービスを取り出して検証、HTTP は通さない。
  • API エンドポイントのテスト — 親: ApiPlatform\Symfony\Bundle\Test\ApiTestCase。上記に加えてリクエストごとに 40ms 前後。HTTP リクエストを実際に通して、HTTP レイヤを含めた挙動を確かめる。

純粋な単体テスト(extends TestCase

カバーするもの:

  • Provider / Processor のオーケストレーション分岐(@throws の目録に書いた各ケース、セクション 8 参照)。
  • DTO のバリデーション制約ごとの検証(リポジトリも DB も不要、Validator だけ起こせばいい)。
  • ドメインサービスのロジック。

依存はコンストラクタ経由で全部モック可能になっていることが前提です。Provider/Processor がリポジトリやドメインサービスを __construct で受け取っている設計(セクション 2)の最大の利益はここで出ます。

サービスのテスト(extends KernelTestCase

カバーするもの:

  • リポジトリの DQL 挙動: 並び順、LIKE、JOIN、DISTINCT。実 DB に触らないと検証できない。
  • DI コンテナの配線が壊れていないことの担保(autowire できなくなった、#[AsAlias] の重複、など)。
  • ドメインサービスのうち、Doctrine を実物で動かさないと意味がない結合パス。

HTTP を通さないので、URL や認証ヘッダーや JSON シリアライズには関与しません。リポジトリ単位、サービス単位で「正しいクエリが組み立てられているか」「正しいエンティティが返るか」を直接検証する場所です。

API エンドポイントのテスト(extends ApiTestCase

カバーするもの:

  • オペレーションあたり1本のハッピーパス。
  • Resource 構成に関する目撃証拠(security: 式、exceptionToStatus マッピング、URI 変数、read: true の発火など)の1〜2本。

含めないもの: 匿名 vs 認証、オーナー vs メンバー、有効 vs 無効入力みたいな分岐。これらは「純粋な単体テスト」が責任を持ちます。

メソッド名が testGetCollectionAnonymousOnClosedProjectIsForbidden のように軸を複数積んできたら、それは API エンドポイントから単体に持っていくサインです。API エンドポイントのメソッド名は基本 testGet / testGetCollection / testPost / testPatch / testDelete で、必要なときだけ短い修飾(testPostRequiresAuthtestGetCollectionPaginationtestGetCollectionForbidden)を足す程度に収まるはず。

なぜこの3層にこだわるのか

API Platform の HTTP テストは1リクエストあたりカーネルブートを含めて 40ms 前後の固定コストが乗ります(本番キャッシュのコールドリクエストが 28ms 程度なのと比べても、十分大きい)。API エンドポイントのテスト1本 ≒ 純粋な単体テスト数千本 ぐらいの速度差があるので、

  • 単体で確かめられることを API エンドポイントで確かめると、CI 時間が線形に伸びる。
  • 逆に、HTTP レイヤ固有の振る舞い(ステータスコード、ヘッダー、JSON-LD シリアライズ)は単体では確かめられないので、API エンドポイントが必須。
  • サービス層は中間で、「実 DB は触りたいが HTTP は要らない」というニッチを安く埋める。

「extends する基底クラスを決める = カバー範囲を宣言する」になっている、ぐらいの規律で運用するのが快適です。


11. DTO の Docblock は クライアント向けの公開仕様

phpdocumentor/reflection-docblock を composer でインストールすると、API Platform はそれを検出して docblock 抽出を有効化します。これにより、API に出るクラス(*Resource.php や入力 DTO)の docblock は OpenAPI の description に流れ、さらに TypeScript の schema.d.ts の JSDoc にまで届きます。

これはつまり、docblock は公開ドキュメントということです。

  • docblock には API 呼び出し側にとってのフィールドの意味を書く。
  • 「どの DTO がバリデータを持っているか」「なぜ writable: false なのか」「誰が fromEntity を呼ぶか」といった内部実装メモは // コメントで書く(コメントはリフレクションから見えない)。

書き分けの例:

final class TaskResource
{
    /**
     * タスクの表示名。プロジェクト内で重複しても構わない。
     * 改行は許容されず、前後の空白はサーバ側でトリムされる。
     */
    #[Assert\NotBlank]
    #[Assert\Length(max: 255)]
    public string $title;

    // writable: false にしてあるのはサーバ採番だから。クライアントから送られても無視。
    #[ApiProperty(writable: false, identifier: true)]
    public int $id;
}

title の docblock は「クライアントが値を組み立てるとき何を考えるべきか」を説明していて、生成された OpenAPI / TS 型のドキュメントに乗ります。id// コメントは「なぜ writable: false なのか」という実装側の都合で、これは公開ドキュメントに出てほしくないので普通のコメントで書きます。

これを混ぜると、TypeScript 型に「Processor が後で assertOwner を呼ぶ」みたいなコメントが流出します。

phpdocumentor/reflection-docblock を入れない選択肢は実質ない

「説明文だけのために依存を増やすのは嫌」と思って phpdocumentor/reflection-docblock の導入をスキップすると、説明文以外の情報も道連れに失います。

具体的には、API Platform は型情報を集めるとき、まず PHP のネイティブ型を見て、docblock の @var / @param / @return を二次情報として補完します。コレクションのように PHP の型システムが要素型を表現できない ケースでは、この docblock 由来の補完が決定的に効きます。

final class ProjectResource
{
    /** @var list<TaskSummary> */
    public array $tasks;
}

phpdocumentor/reflection-docblock が無いと、API Platform は tasks を PHP の array としてしか認識できず、生成される OpenAPI は「型不明の配列」、TypeScript 型は unknown[] 相当になります。@var list<TaskSummary> というヒントは構文としては書けますが、誰も読まないので OpenAPI に反映されません。

結果として、「説明文を諦めても型情報は守れる」という妥協は成り立ちません。公開仕様としての DTO を持つ限り、phpdocumentor/reflection-docblock は事実上必須の依存 と考えて入れておきましょう。


まとめ

API Platform は強力ですが、エンティティに #[ApiResource] を載せる最短コースの先には、

  • 永続化と API 契約が癒着して片方を変えるたびにもう片方が壊れる、
  • バリデーションがエンティティとリクエストの両方に重複し、CLI から呼んだときと API から呼んだときで挙動が違う、

といった「強力さゆえの罠」が待ち構えています。

  • DTO とエンティティを分ける、
  • Provider と Processor の責務を DRY に、
  • 入出力で変化する DTO の設計にこだわる、
  • 無駄なテストを避けつつ、やるべきテストは網羅する、
  • docblock を公開ドキュメントとして扱う、

このあたりを最初から守れば、API Platform は「賢い CRUD ジェネレータ」ではなく「ちゃんと型のついた HTTP 境界を作るためのツール」として長く使えます。


何のことだか分からなかった方は、こちらの書籍の 3章、5章、6章 をお読みください。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?