Laravel APIリソースとRESTful API開発
APIリソースはLaravelの「データラッパー」です。クライアントに公開するフィールド, フォーマット, リレーションのネストを制御し, API出力が一貫した標準に従うことを保証します。
1. 学ぶこと
- リソースクラスとResourceCollection:データ変換とフォーマット
- ネストされた関連リソース:
whenLoaded()による条件付き関連リソースの読み込み - ページネーション, フィルタリング, ソートのAPIリソースへのカプセル化
- RESTful API設計仕様:URI, 動詞, ステータスコード, エラーフォーマット
- APIバージョン管理:v1/v2ルートグループとリソース継承
2. フロントエンド開発者の本当の話
(1) 痛点:APIが返すJSONフォーマットがフロントエンドをクラッシュさせる
AliceはShopMetrics APIの統合中に悪夢に遭遇しました。/shopsは{shops: [...]}を返しましたが, /ordersは{data: [...]}を返し, /productsは単なる配列を返しました。フィールド名も統一されておらず, created_at, createdAt, createdDateが混在していました。パスワードや内部IDもJSONに露出していました。フロントエンドの適応コードがビジネスロジックより多くなっていました。
(2) APIリソースの解決策
APIリソースはJSON出力フォーマットを統一的に制御し, フィールド名の一貫性を保ち, 機密フィールドを隠し, 関連の条件付きネストを行い, 標準的なページネーションフォーマットに従います。
// app/Http/Resources/ShopResource.php
class ShopResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'status' => $this->status,
'products' => ProductResource::collection($this->whenLoaded('products')),
'created_at' => $this->created_at->toISOString(),
];
}
}
(3) 成果
AliceがAPIリソースを使い始めた後, すべてのエンドポイントが標準化され, フロントエンドの適応コードが80%削減され, パスワードなどの機密フィールドが自動的に非表示になりました。
3. リソースクラスとResourceCollection
(1) リソースの作成
php artisan make:resource ShopResource
php artisan make:resource ProductResource
php artisan make:resource OrderResource
# 作成されるファイル: app/Http/Resources/ShopResource.php
(2) リソースクラス (単一レコード)
// app/Http/Resources/ShopResource.php
class ShopResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'description' => $this->description,
'status' => $this->status,
'revenue' => (float) $this->revenue,
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
];
}
}
// 使い方 — 単一リソース
return new ShopResource($shop);
// {"data": {"id": 1, "name": "Alice Store", ...}}
(3) ResourceCollection (複数レコード)
// リソースコレクションの使用
return ShopResource::collection($shops);
// {"data": [...], "links": {...}, "meta": {...}}
// カスタムコレクション
php artisan make:resource ShopCollection
class ShopCollection extends ResourceCollection
{
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
'meta' => [
'total_shops' => $this->collection->count(),
],
];
}
}
| 型 | 戻り値フォーマット | 適用場面 |
|---|---|---|
new Resource($model) |
{data: {...}} |
単一レコード |
Resource::collection($models) |
{data: [...], links, meta} |
一覧 + ページネーション |
CustomCollection |
カスタムフォーマット | 追加のmetaが必要な場合 |
(1) ▶ サンプル:ShopMetrics ShopResource
// app/Http/Resources/ShopResource.php
class ShopResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'description' => $this->whenNotNull($this->description),
'status' => $this->status,
'revenue' => [
'raw' => (float) $this->revenue,
'formatted' => $this->revenue_formatted,
],
'products_count' => $this->whenCounted('products'),
'orders_count' => $this->whenCounted('orders'),
'products' => ProductResource::collection($this->whenLoaded('products')),
'latest_order' => new OrderResource($this->whenLoaded('latestOrder')),
'links' => [
'self' => route('api.v1.shops.show', $this->id),
'products' => route('api.v1.products.index', ['shop_id' => $this->id]),
],
'created_at' => $this->created_at->toISOString(),
];
}
}
出力:
// 実行成功
4. ネストされた関連リソース
(1) whenLoaded()条件付き読み込み
// 事前読み込みされている場合のみ関連を含める
'products' => ProductResource::collection($this->whenLoaded('products')),
// productsがwith()で読み込まれていない場合, nullを返しJSONから省略される
// Shop::find(1) → JSONにproductsキーなし
// Shop::with('products')->find(1) → productsが含まれる
// 単一リレーション
'user' => new UserResource($this->whenLoaded('user')),
// カウントのみ (データなし)
'products_count' => $this->whenCounted('products'),
(2) ネストリソースの定義
// app/Http/Resources/OrderResource.php
class OrderResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'order_number' => $this->order_number,
'status' => $this->status,
'total' => (float) $this->total,
'items' => OrderItemResource::collection($this->whenLoaded('items')),
'shop' => new ShopBriefResource($this->whenLoaded('shop')),
'user' => new UserBriefResource($this->whenLoaded('user')),
'created_at' => $this->created_at->toISOString(),
];
}
}
// 簡易リソース — ネスト用の最小データ
class ShopBriefResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
];
}
}
| メソッド | 説明 | 問題を回避する方法 |
|---|---|---|
whenLoaded() |
結合が読み込まれた後のみ出力 | 遅延読み込みによるN+1を回避 |
whenCounted() |
カウント後のみ出力 | 余分なクエリを回避 |
whenNotNull() |
nullでない場合のみ出力 | 空フィールドの整理 |
when() |
条件付き出力 | 柔軟な制御 |
| 簡易リソース | ネストには軽量版を使用 | ネストループを回避 |
(1) ▶ サンプル:ShopMetricsネストリソース
// app/Http/Resources/OrderItemResource.php
class OrderItemResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'product' => new ProductBriefResource($this->whenLoaded('product')),
'quantity' => $this->quantity,
'unit_price' => (float) $this->price,
'subtotal' => (float) ($this->price * $this->quantity),
];
}
}
// app/Http/Resources/ProductBriefResource.php
class ProductBriefResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'sku' => $this->sku,
];
}
}
出力:
// 実行成功
5. RESTful API設計仕様
(1) URIとHTTP動詞
| 操作 | HTTP | URI | 説明 |
|---|---|---|---|
| 一覧 | GET | /api/v1/shops | コレクションを返す |
| 作成 | POST | /api/v1/shops | リソースを作成 |
| 詳細 | GET | /api/v1/shops/{id} | 単一レコードを返す |
| 全体更新 | PUT | /api/v1/shops/{id} | リソースを置換 |
| 部分更新 | PATCH | /api/v1/shops/{id} | フィールドを変更 |
| 削除 | DELETE | /api/v1/shops/{id} | リソースを削除 |
(2) HTTPステータスコード
| ステータスコード | 意味 | 使用場面 |
|---|---|---|
| 200 | OK | 成功レスポンス |
| 201 | Created | リソース作成成功 |
| 204 | No Content | 削除成功 |
| 400 | Bad Request | 無効なリクエスト形式 |
| 401 | Unauthorized | 未認証 |
| 403 | Forbidden | 権限なし |
| 404 | Not Found | リソースが存在しない |
| 422 | Unprocessable Entity | バリデーション失敗 |
| 429 | Too Many Requests | レート制限 |
| 500 | Internal Server Error | サーバーエラー |
(3) エラーレスポンスフォーマット
{
"success": false,
"message": "Validation failed.",
"errors": {
"name": ["The name field is required."],
"email": ["The email must be a valid email address."]
}
}
(1) ▶ サンプル:ShopMetrics RESTful APIエンドポイント設計
// routes/api.php
Route::prefix('v1')->middleware('auth:sanctum')->group(function () {
// 店舗
Route::apiResource('shops', Api\V1\ShopController::class);
Route::get('shops/{shop}/analytics', [Api\V1\ShopAnalyticsController::class, 'show']);
// 商品 (ネスト後にshallow)
Route::apiResource('shops.products', Api\V1\ProductController::class)->shallow();
// 注文
Route::apiResource('orders', Api\V1\OrderController::class)->only(['index', 'show', 'update']);
Route::post('orders/{order}/cancel', [Api\V1\OrderController::class, 'cancel']);
// カテゴリ
Route::apiResource('categories', Api\V1\CategoryController::class)->only(['index', 'show']);
// 分析
Route::get('analytics/overview', [Api\V1\AnalyticsController::class, 'overview']);
});
出力:
// 実行成功
6. フィルタリング, ソート, ページネーションのカプセル化
(1) クエリフィルタ基底クラス
// app/Filters/QueryFilter.php
abstract class QueryFilter
{
public function __construct(protected Request $request) {}
public function apply(Builder $query): Builder
{
foreach ($this->filters() as $filter => $value) {
if (method_exists($this, $filter) && $value !== null) {
$this->$filter($query, $value);
}
}
return $query;
}
protected function filters(): array
{
return $this->request->all();
}
}
(2) 個別フィルタ
// app/Filters/ShopFilter.php
class ShopFilter extends QueryFilter
{
public function search(Builder $query, string $value): Builder
{
return $query->where('name', 'like', "%{$value}%");
}
public function status(Builder $query, string $value): Builder
{
return $query->where('status', $value);
}
public function min_revenue(Builder $query, float $value): Builder
{
return $query->where('revenue', '>=', $value);
}
public function sort(Builder $query, string $value): Builder
{
$direction = str_starts_with($value, '-') ? 'desc' : 'asc';
$field = ltrim($value, '-');
return $query->orderBy($field, $direction);
}
}
(1) ▶ サンプル:フィルタ付きShopMetrics APIエンドポイント
// app/Http/Controllers/Api/V1/ShopController.php
class ShopController extends Controller
{
public function index(ShopFilter $filter): JsonResponse
{
$shops = Shop::where('tenant_id', tenant()->id)
->filter($filter)
->withCount(['products', 'orders'])
->paginate(request()->integer('per_page', 15));
return ShopResource::collection($shops);
}
public function store(StoreShopRequest $request): JsonResponse
{
$shop = Shop::create(array_merge($request->validated(), ['tenant_id' => tenant()->id]));
return response()->json([
'message' => 'Shop created.',
'data' => new ShopResource($shop),
], 201);
}
public function show(Shop $shop): JsonResponse
{
$shop->load(['products' => fn ($q) => $q->active()->latest()->take(10)]);
return new ShopResource($shop);
}
public function update(UpdateShopRequest $request, Shop $shop): JsonResponse
{
$shop->update($request->validated());
return new ShopResource($shop);
}
public function destroy(Shop $shop): Response
{
$shop->delete();
return response()->noContent();
}
}
出力:
// 実行成功
7. APIバージョン管理
(1) ルートグループのバージョン管理
// routes/api.php
Route::prefix('v1')->group(function () {
Route::apiResource('shops', Api\V1\ShopController::class);
});
Route::prefix('v2')->group(function () {
Route::apiResource('shops', Api\V2\ShopController::class);
});
(2) リソース継承
// V1 ShopResource
namespace App\Http\Resources\Api\V1;
class ShopResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'status' => $this->status,
];
}
}
// V2 ShopResource — 継承してフィールドを追加
namespace App\Http\Resources\Api\V2;
class ShopResource extends \App\Http\Resources\Api\V1\ShopResource
{
public function toArray(Request $request): array
{
return array_merge(parent::toArray($request), [
'revenue' => (float) $this->revenue,
'products_count' => $this->whenCounted('products'),
'links' => [
'self' => route('api.v2.shops.show', $this->id),
],
]);
}
}
| バージョン管理戦略 | アプローチ | メリット・デメリット |
|---|---|---|
| URLプレフィックス | /api/v1/, /api/v2/ |
✅ シンプルで分かりやすい |
| ヘッダー | Accept: application/vnd.api.v2+json |
よりRESTfulだが複雑 |
| クエリ | ?version=2 |
非推奨, RESTfulではない |
(1) ▶ サンプル:ShopMetrics APIバージョンルーティング
// routes/api.php
Route::prefix('v1')->middleware('auth:sanctum')->group(function () {
Route::apiResource('shops', Api\V1\ShopController::class);
Route::apiResource('products', Api\V1\ProductController::class);
Route::apiResource('orders', Api\V1\OrderController::class)->only(['index', 'show']);
});
Route::prefix('v2')->middleware('auth:sanctum')->group(function () {
Route::apiResource('shops', Api\V2\ShopController::class);
Route::apiResource('products', Api\V2\ProductController::class);
Route::apiResource('orders', Api\V2\OrderController::class);
// V2は注文の完全なCRUD + 分析を追加
Route::get('analytics', [Api\V2\AnalyticsController::class, 'overview']);
});
出力:
// 実行成功
8. 総合例:ShopMetrics APIリソースの完全なプロセス
// ============================================
// 総合例: ShopMetrics APIリソース
// 対象: リソース, リレーション, フィルタリング, ページネーション, バージョン管理
// ============================================
// app/Http/Resources/Api/V1/OrderResource.php
class OrderResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'order_number' => $this->order_number,
'status' => $this->status,
'subtotal' => (float) $this->subtotal,
'discount' => (float) $this->discount,
'total' => (float) $this->total,
'items_count' => $this->whenCounted('items'),
'items' => OrderItemResource::collection($this->whenLoaded('items')),
'shop' => new ShopBriefResource($this->whenLoaded('shop')),
'user' => [
'id' => $this->whenLoaded('user')?->id,
'name' => $this->whenLoaded('user')?->name,
],
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
];
}
}
// 完全なパイプライン付きコントローラ
class OrderController extends Controller
{
public function index(OrderFilter $filter): JsonResponse
{
$orders = Order::where('tenant_id', tenant()->id)
->filter($filter)
->with(['shop', 'user'])
->withCount('items')
->latest()
->paginate(request()->integer('per_page', 15));
return OrderResource::collection($orders);
}
public function show(Order $order): JsonResponse
{
$order->load(['items.product', 'shop', 'user']);
return new OrderResource($order);
}
public function update(UpdateOrderRequest $request, Order $order): JsonResponse
{
$order->update($request->validated());
return new OrderResource($order->fresh()->load('items.product'));
}
}
❓ よくある質問
Resourceを使うこととモデルのJSONを直接返すことの違いは何ですか?Resourceを使うと出力フィールド, フォーマット値, ネストされたリレーションを正確に制御できます。APIでは必ずResourceを使用してください。whenLoadedとリレーションの直接アクセスの違いは何ですか?whenLoadedは事前読み込みされている場合のみリレーションを出力し, 読み込まれていない場合はnullを返し, JSONから自動的に除外されます。JsonResource::collection()のpaginationResponse()メソッドを使ってページネーションフォーマットをカスタマイズできます。$request->user()またはauth()->user()を使用します。リソースのtoArray()メソッドはRequestパラメータを受け取るため, ユーザーの権限に基づいて出力フィールドを決定できます。$this->when()を使い, コンテキストに応じて動的にフィールドを出力します。📖 まとめ
- APIリソース:フィールドの公開, フォーマット, ネストリレーションの制御
whenLoaded()でリレーションの条件付き読み込みを行い, N+1遅延読み込みを回避- ネスト場面では簡易リソースを使用し, 循環参照を回避
- RESTful APIはURI + HTTP動詞 + ステータスコードの仕様に従う
- QueryFilterがフィルタリングとソートロジックをカプセル化し, コントローラをクリーンに保つ
- APIバージョンはURLプレフィックス (/v1/, /v2/)で示し, リソースは継承して再利用可能
📝 練習問題
-
基本問題 (⭐):ShopMetrics用にShopResource, ProductResource, OrderResourceの3つのリソースクラスを作成してください。コントローラでモデルの直接返却を置き換え, JSONフォーマットの一貫性を確保してください。
-
応用問題 (⭐⭐):ShopFilterクエリフィルタを実装し, search, status, min_revenue, sortパラメータをサポートしてください。ShopController::indexで使用し, Postmanを使ってフィルタリング機能をテストしてください。
-
チャレンジ (⭐⭐⭐):V1とV2の2つのAPIバージョンを設計してください。V1は基本フィールドのみを返し, V2は追加でrevenue, products_count, linksを返すようにします。リソース継承を使って実装し, V1エンドポイントが既存のクライアントを壊さないことを確認してください。



