404 Not Found

404 Not Found


nginx

Laravel APIリソースとRESTful API開発

APIリソースはLaravelの「データラッパー」です。クライアントに公開するフィールド, フォーマット, リレーションのネストを制御し, API出力が一貫した標準に従うことを保証します。

1. 学ぶこと


2. フロントエンド開発者の本当の話

(1) 痛点:APIが返すJSONフォーマットがフロントエンドをクラッシュさせる

AliceはShopMetrics APIの統合中に悪夢に遭遇しました。/shops{shops: [...]}を返しましたが, /orders{data: [...]}を返し, /productsは単なる配列を返しました。フィールド名も統一されておらず, created_at, createdAt, createdDateが混在していました。パスワードや内部IDもJSONに露出していました。フロントエンドの適応コードがビジネスロジックより多くなっていました。

(2) APIリソースの解決策

APIリソースはJSON出力フォーマットを統一的に制御し, フィールド名の一貫性を保ち, 機密フィールドを隠し, 関連の条件付きネストを行い, 標準的なページネーションフォーマットに従います。

PHP
// 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) リソースの作成

BASH
php artisan make:resource ShopResource
php artisan make:resource ProductResource
php artisan make:resource OrderResource
# 作成されるファイル: app/Http/Resources/ShopResource.php

(2) リソースクラス (単一レコード)

PHP
// 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 (複数レコード)

PHP
// リソースコレクションの使用
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

PHP
// 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(),
        ];
    }
}

出力:

TEXT
// 実行成功

4. ネストされた関連リソース

(1) whenLoaded()条件付き読み込み

PHP
// 事前読み込みされている場合のみ関連を含める
'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) ネストリソースの定義

PHP
// 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ネストリソース

PHP
// 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,
        ];
    }
}

出力:

TEXT
// 実行成功

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) エラーレスポンスフォーマット

JSON
{
    "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エンドポイント設計

PHP
// 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']);
});

出力:

TEXT
// 実行成功

6. フィルタリング, ソート, ページネーションのカプセル化

(1) クエリフィルタ基底クラス

PHP
// 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) 個別フィルタ

PHP
// 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エンドポイント

PHP
// 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();
    }
}

出力:

TEXT
// 実行成功

7. APIバージョン管理

(1) ルートグループのバージョン管理

PHP
// 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) リソース継承

PHP
// 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バージョンルーティング

PHP
// 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']);
});

出力:

TEXT
// 実行成功

8. 総合例:ShopMetrics APIリソースの完全なプロセス

PHP
// ============================================
// 総合例: 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'));
    }
}

❓ よくある質問

Q Resourceを使うこととモデルのJSONを直接返すことの違いは何ですか?
A モデルを直接返すとすべてのフィールド (パスワードなどの機密データを含む)が露出し, フォーマットを制御できません。Resourceを使うと出力フィールド, フォーマット値, ネストされたリレーションを正確に制御できます。APIでは必ずResourceを使用してください。
Q whenLoadedとリレーションの直接アクセスの違いは何ですか?
A リレーションの直接アクセスは遅延読み込みをトリガーします (N+1問題)。whenLoadedは事前読み込みされている場合のみリレーションを出力し, 読み込まれていない場合はnullを返し, JSONから自動的に除外されます。
Q APIは何バージョン必要ですか?
A 通常, 現在のバージョンと前のバージョンの2つを維持します。新バージョンリリース時には6ヶ月の移行期間を設け, 期間終了後は旧バージョンが410 Goneステータスを返します。同時に多くのバージョンを維持することは避けてください。
Q ResourceCollectionのページネーションフォーマットはカスタマイズできますか?
A はい。カスタムCollectionクラスを作成してtoArray()メソッドをオーバーライドするか, JsonResource::collection()paginationResponse()メソッドを使ってページネーションフォーマットをカスタマイズできます。
Q リソース内で認証済みユーザーにアクセスするにはどうしますか?
A $request->user()またはauth()->user()を使用します。リソースのtoArray()メソッドはRequestパラメータを受け取るため, ユーザーの権限に基づいて出力フィールドを決定できます。
Q 簡易リソースと完全リソースの間に重複コードがある場合はどうしますか?
A 簡易リソースに完全リソースを継承させ, toArray()のみをオーバーライドして少ないフィールドを出力します。または完全リソース内で$this->when()を使い, コンテキストに応じて動的にフィールドを出力します。

📖 まとめ


📝 練習問題

  1. 基本問題 (⭐):ShopMetrics用にShopResource, ProductResource, OrderResourceの3つのリソースクラスを作成してください。コントローラでモデルの直接返却を置き換え, JSONフォーマットの一貫性を確保してください。

  2. 応用問題 (⭐⭐):ShopFilterクエリフィルタを実装し, search, status, min_revenue, sortパラメータをサポートしてください。ShopController::indexで使用し, Postmanを使ってフィルタリング機能をテストしてください。

  3. チャレンジ (⭐⭐⭐):V1とV2の2つのAPIバージョンを設計してください。V1は基本フィールドのみを返し, V2は追加でrevenue, products_count, linksを返すようにします。リソース継承を使って実装し, V1エンドポイントが既存のクライアントを壊さないことを確認してください。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%