出力の設計 — API Resource でレスポンスの形を決める
ここまで、レスポンスはモデルをそのまま返してきました。
return response()->json($order, 201);
動きますし、開発中は手軽です。この章では、なぜそれを続けてはいけないのかと、代わりに何を書くのかを扱います。
モデルを直接返してはいけない理由
response()->json($order) は、Order モデルの全カラムを JSON に変換します。第3章で作った orders テーブルの列がそのまま並びます。
{
"id": 1,
"customer_email": "taro.test@example.com",
"status": "pending",
"total_amount": 12800,
"created_at": "2026-08-07T10:00:00.000000Z",
"updated_at": "2026-08-07T10:00:00.000000Z"
}
id と timestamps を含めて 6 つのキーが出ています。問題は、この形がテーブル定義に連動して勝手に変わることです。
半年後、運用チームから「電話での問い合わせ内容をメモしたい」という要望が出たとします。マイグレーションで internal_memo カラムを足します。テーブルにカラムが増えただけで、API のレスポンスにもそれが現れます。
{
"id": 1,
"customer_email": "taro.test@example.com",
"internal_memo": "クレーム対応中。強めの口調。上長へエスカレーション済み",
"status": "pending",
"total_amount": 12800
}
カラムを足した人は、それが外部に公開されるとは考えていません。マイグレーションを書いてレビューを通し、テストも通ります。誰も気づかないまま公開されます。
$hidden に追加すれば隠せますが、それは「隠すべきものを列挙する」やり方です。新しいカラムを足すたびに、隠す必要があるかを判断して忘れずに書き足す運用になります。1 回忘れれば漏れます。
逆にすべきです。公開するものを列挙します。 列挙していないカラムは、テーブルに何が増えても外には出ません。
API Resource を作る
Laravel はこの列挙を書く場所を用意しています。
php artisan make:resource OrderResource
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class OrderResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'customer_email' => $this->customer_email,
'status' => $this->status,
'total_amount' => $this->total_amount,
'ordered_at' => $this->created_at,
];
}
}
コントローラでは、モデルの代わりにこれを返します。
public function store(StoreOrderRequest $request, OrderPlacementService $service)
{
$validated = $request->validated();
$order = $service->place($validated['customer_email'], $validated['items']);
return (new OrderResource($order))
->response()
->setStatusCode(201);
}
toArray() に書いたキーだけが JSON になります。internal_memo を足しても、ここに書かないかぎり出ません。
キー名をテーブルのカラム名と変えられる点にも注目してください。上の例では created_at を ordered_at として公開しています。内部の都合とクライアントに見せる名前を切り離せます。 テーブルのカラム名を変えるリファクタリングをしても、Resource の中で吸収すれば API は変わりません。
リレーションを含める
注文の詳細では、明細も一緒に返したくなります。
php artisan make:resource OrderItemResource
public function toArray(Request $request): array
{
return [
'product_id' => $this->product_id,
'quantity' => $this->quantity,
'unit_price' => $this->unit_price,
'subtotal' => $this->unit_price * $this->quantity,
];
}
OrderResource から呼びます。
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'customer_email' => $this->customer_email,
'status' => $this->status,
'total_amount' => $this->total_amount,
'items' => OrderItemResource::collection($this->whenLoaded('items')),
'ordered_at' => $this->created_at,
];
}
subtotal のように、テーブルに無い値も返せます。単価と数量から計算した小計は、クライアント側で計算させるより API が返したほうが親切です。計算方法が変わったときに、修正が 1 箇所で済みます。
whenLoaded を使う理由
$this->whenLoaded('items') は、リレーションが既に読み込まれているときだけ含めます1。
$this->items と書いた場合との違いは大きいです。素直に書くと、Resource が変換されるたびに明細を取りに行くクエリが飛びます。注文一覧で 100 件を返すなら、明細の SELECT が 100 回追加されます。
whenLoaded を使えば、読み込まれていないときはキーごと出力から消えます。呼び出し側が明細を必要とするなら、先に読み込んでおきます。
// 一覧: 明細は返さない
$orders = Order::paginate(20);
// 詳細: 明細も返す
$order = Order::with('items')->findOrFail($id);
この「先に読み込む」がどういう仕組みで、なぜ必要なのかは次章で扱います。ここでは whenLoaded を使う癖を付けておいてください。
条件付きで項目を出し分ける
同じリソースでも、見る人によって出す項目を変えたいことがあります。
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'status' => $this->status,
'total_amount' => $this->total_amount,
'internal_memo' => $this->when($request->user()?->isAdmin(), $this->internal_memo),
];
}
when() の第 1 引数が false なら、そのキーは出力に現れません1。null が入るのではなく、キーごと消えます。
ただし、この書き方には注意が要ります。1 つの Resource が複数の読み手を相手にすると、条件が増えて読みにくくなります。管理者向けの項目が 5 個を超えたあたりで、AdminOrderResource として分けるほうが見通しがよくなります。
data ラッピング
Resource を返すと、既定で data というキーに包まれます1。
{
"data": {
"id": 1,
"status": "pending"
}
}
一見すると余計ですが、意味があります。ページネーションの情報やメタデータを、本体と混ざらない場所に置けます。次章で一覧 API を作るとき、こうなります。
{
"data": [ ... ],
"links": { "first": "...", "next": "..." },
"meta": { "current_page": 1, "total": 87 }
}
data が無いと、total のようなキーが商品のフィールドと同じ階層に並びます。商品に total というフィールドを足したくなったとき、衝突します。
外す方法もあります。
use Illuminate\Http\Resources\Json\JsonResource;
public function boot(): void
{
JsonResource::withoutWrapping();
}
この連載では外しません。 ページネーションのメタ情報を置く場所が必要だからです。
外すかどうかは、公開前に決めてください。公開後に切り替えると、すべてのクライアントが壊れます。 レスポンスの階層が 1 つ変わるので、response.data.id で読んでいたコードが response.id になります。
表現を決める
Resource を作るということは、それぞれの値をどう見せるかを決めるということです。決めておかないと、章ごとにばらばらの形になります。
日付
Laravel の既定は ISO 8601 形式です。
"ordered_at": "2026-08-07T10:00:00.000000Z"
タイムゾーンが Z (UTC) で明示されているので、クライアント側で現地時間に変換できます。API が日本時間の文字列を返すより安全です。 「2026-08-07 19:00」だけを返すと、それがどのタイムゾーンかはドキュメントを読まないと分かりません。
表示用に整形したいときは、両方を返す手もあります。
'ordered_at' => $this->created_at->toIso8601String(),
'ordered_at_label' => $this->created_at->timezone('Asia/Tokyo')->format('Y年n月j日 H:i'),
金額
第3章で整数の円として持つと決めました。そのまま整数で返します。
"total_amount": 12800
"¥12,800" のような整形済み文字列を API が返すのは避けます。計算に使えなくなりますし、通貨記号や桁区切りは表示側の都合です。多言語対応をするなら、なおさら表示側の仕事です。
enum
status は第3章で OrderStatus にキャストしました。Resource でそのまま返すと、backed enum の値が出ます。
"status": "pending"
クライアントはこの文字列で分岐します。表示用のラベルを一緒に返すかどうかは、判断が要ります。
'status' => $this->status->value,
'status_label' => match ($this->status) {
OrderStatus::Pending => '受付済み',
OrderStatus::Confirmed => '確定',
OrderStatus::Shipped => '発送済み',
OrderStatus::Cancelled => 'キャンセル',
},
ラベルをサーバー側で持つと、表現を変えたいときにクライアントの更新を待たずに済みます。一方、多言語対応をするならクライアント側に持たせるほうが素直です。この連載では値だけを返し、ラベルは表示側の責任とします。
null と空配列
「値が無い」の表し方を統一します。
- 値が無い項目は
nullを返す。キーごと消すのはwhen()を使った意図的な場合だけ - 配列が空なら
[]を返す。nullにしない
配列を null にすると、クライアント側で for を回す前に毎回チェックが要ります。空配列なら 0 回まわるだけです。
選択肢: JSON:API resources
Laravel 13 は JSON:API という仕様に沿った Resource を作る仕組みを持っています2。
php artisan make:resource OrderResource --json-api
JSON:API はレスポンスの構造を細かく定めた仕様で、クライアントが複数種類あるときに効きます。「関連を含めるかどうかをクライアントが指定する」「返すフィールドをクライアントが選ぶ」といった機能が仕様として決まっているので、実装ごとに違う形にならずに済みます。
一方、構造が決まっているぶん出力は冗長になります。単一のフロントエンドだけが使う API では、素の JsonResource で十分なことが多いです。
この連載では素の JsonResource を使います。 JSON:API を選ぶかどうかは、クライアントの数と、仕様に合わせる価値が構造の複雑さに見合うかで決めてください。詳細は Eloquent: API Resources を参照してください。
本番で効く注意点
フィールドは足せるが、消せない
公開した API のレスポンスからフィールドを削除すると、それを読んでいるクライアントが壊れます。名前を変えるのも同じです。
追加は壊しません。知らないキーが増えても、クライアントは無視するだけです。
この非対称性から、設計時の姿勢が決まります。迷ったら出さないでください。あとで必要になったら足せます。逆に、とりあえず出しておいて後で消す、はできません。
内部 ID を出すかどうかを意識する
user_id や product_id のような外部キーをそのまま返すと、クライアントはその ID で別のエンドポイントを叩けると考えます。実際には叩けないなら、返す意味がありません。
返すなら、その ID を使えるエンドポイントも用意します。返さないなら、必要な情報を展開して含めます。
'product_id' => $this->product_id, // /api/v1/products/{id} が叩ける前提
'product' => new ProductResource($this->whenLoaded('product')), // 展開して返す
Resource の中でクエリを発行しない
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'product_name' => Product::find($this->product_id)->name, // 1 件ごとに SELECT
];
}
Resource は変換だけを担当します。ここでクエリを書くと、一覧で 100 件返すときに 100 回発行されます。しかも呼び出し側からは見えないので、遅い原因を探すときに気づきにくい場所です。
必要なデータは、コントローラかサービスクラスで先に読み込んでおきます。次章で詳しく扱います。
日付の形式を途中で変えない
ISO 8601 と Y-m-d H:i:s が混在すると、クライアント側のパース処理が分岐します。最初に決めて全 Resource で揃えてください。 揃っていれば、クライアントは 1 つの変換関数だけを持てば済みます。
まとめ
- モデルを直接返すと、テーブルにカラムを足した瞬間に公開される。隠すものを列挙するのでなく、公開するものを列挙する
- Resource のキー名はテーブルのカラム名と切り離せる。内部の名前を変えても API を保てる
whenLoadedを使うと、読み込まれていないリレーションはキーごと消えるdataラッピングはページネーションのメタ情報の置き場所になる。公開後に切り替えると全クライアントが壊れる- 日付は ISO 8601、金額は整数、enum は値。整形はクライアントの仕事
- フィールドは足せるが消せない。迷ったら出さない
次に読む
次章 一覧 API — ページネーション・フィルタ・N+1 では一覧 API を作ります。件数が増えても壊れないよう、ページネーションを最初から入れます。この章で whenLoaded を使った理由でもある N+1 問題も、実際に発生させてから潰します。
練習問題
次の Resource には、本番で問題になる箇所が 2 つあります。指摘してください
class ProductResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'price' => '¥' . number_format($this->price),
'stock_quantity' => Stock::where('product_id', $this->id)->value('quantity'),
];
}
}
解答例
1 つ目: 金額を整形済みの文字列で返している。
"¥12,800" を受け取ったクライアントは、これを計算に使えません。合計を出すには文字列から記号と桁区切りを取り除いてから数値に戻すことになり、その処理が正しい保証はありません。桁区切りの位置は言語によって変わりますし、通貨記号も同じです。
整数のまま返し、表示の整形はクライアントに任せます。
2 つ目: Resource の中でクエリを発行している。
Stock::where(...) は、この Resource が変換されるたびに 1 回ずつ SELECT を投げます。商品一覧で 50 件返せば 50 回です。
しかもこのクエリはコントローラから見えません。「一覧 API が遅い」と言われて調べるとき、コントローラとサービスクラスを読んでも原因が見つからず、Resource まで到達して初めて分かります。
在庫が必要なら、コントローラ側で Product::with('stock')->paginate(20) のように先に読み込み、Resource では $this->whenLoaded('stock') で参照します。
公開済みの API で、レスポンスの customer_email を customer.email のようにネストした構造へ変えたい要望が出ました。どう応じますか
解答例
そのままでは変えられません。 response.data.customer_email を読んでいるクライアントは、undefined を受け取るようになります。エラーにならず、画面に空欄が出るだけなので、発見が遅れます。
取れる選択肢は 3 つです。
両方返す: customer_email を残したまま customer オブジェクトを足します。追加は壊さないので、すぐ実施できます。移行期間を設けて、クライアントが customer.email に切り替え終わったら customer_email を消します。消すときには告知が要ります。
バージョンを上げる: /api/v2 を作り、構造を整理します。変更が 1 箇所でなく全体に及ぶなら、こちらのほうが結果的に安く済みます。ただし v1 と v2 の両方を維持するコストがかかります。
変えない: 要望の背景を確認します。「顧客情報が増えたときに階層が欲しい」なら将来の話なので、実際に増えるときまで待てます。「見た目が整う」だけなら、クライアントを壊す価値はありません。
判断の軸は第4章と同じです。追加は壊さず、削除と名前の変更は壊す。この非対称性があるので、最初の設計で「迷ったら出さない」を守っておくと、後から選べる幅が広がります。