404 Not Found

404 Not Found


nginx

Laravel リクエストとレスポンス

リクエストとレスポンスはLaravelの「入出力」です。リクエストが来たときにデータを取り出し, レスポンスが出るときにデータを梱包します。どちらのプロセスも正確な制御が必要です。

1. 学ぶ内容


2. API開発者からの実話

(1) ペインポイント:エンドポイントごとにデータフォーマットが違う

BobはShopMetrics APIに10のエンドポイントを書きましたが, あるものは{data: [...]}, あるものは{shops: [...]}, あるものは配列をそのまま返します。AliceがフロントエンドにAPIを統合する際, エンドポイントごとに異なる解析ロジックを書かなければなりませんでした。さらに困ったことに, ページネーションデータがtotal_pageslast_pageと異なる形式で返されるため, フロントエンドのページネーションコンポーネントを統一できませんでした。

(2) 標準化レスポンスのソリューション

LaravelのAPIリソースとレスポンスマクロが出力フォーマットを標準化します。すべてのエンドポイントが同じJSON構造を返し, ページネーションデータも一貫したフォーマットに従います。

PHP
// 標準化レスポンス — すべてのエンドポイントがこのフォーマットに従う
return ShopResource::collection($shops);
// {
//   "data": [...],
//   "meta": {"current_page": 1, "total": 50},
//   "links": {"next": "...", "prev": "..."}
// }

(3) 成果

Aliceが統一フォーマットを採用した後, フロントエンド解析ロジックのセット数は10から1に減り, ページネーションコンポーネントの再利用率は100%に達しました。


3. リクエストオブジェクト

(1) 入力の取得

PHP
// 単一入力の取得
$name = $request->input('name');
$name = $request->input('name', 'デフォルト値');

// クエリ文字列からのみ取得
$sort = $request->query('sort', 'created_at');

// 複数入力の取得
$filtered = $request->only(['name', 'email', 'status']);
$filtered = $request->except(['password', '_token']);

// 存在チェック
if ($request->has('search')) { ... }
if ($request->filled('search')) { ... }  // 存在 + 空でない
if ($request->missing('search')) { ... }

// 型変換
$page = $request->integer('page', 1);
$active = $request->boolean('active', false);
$date = $request->date('published_at', 'Y-m-d');
メソッド 説明 違い
input() すべての入力 (query+body) 最も一般的
query() クエリ文字列のみ GETパラメータ
post() POSTボディのみ フォームデータ
only() 指定フィールドを抽出 ホワイトリスト
except() 指定フィールドを除外 ブラックリスト
has() フィールドが存在 null値を含む
filled() フィールドが存在し空でない null値を除外

(1) ▶ サンプル:ShopMetrics APIリクエストの処理

PHP
// app/Http/Controllers/Api/ShopController.php
public function index(Request $request): JsonResponse
{
    $query = Shop::where('tenant_id', tenant()->id);

    // 検索フィルター
    if ($request->filled('search')) {
        $query->where('name', 'like', "%{$request->search}%");
    }

    // ステータスフィルター
    if ($request->filled('status')) {
        $query->where('status', $request->status);
    }

    // ソート
    $sortField = $request->input('sort', 'created_at');
    $sortDir = $request->input('direction', 'desc');
    $query->orderBy($sortField, $sortDir);

    // ページネーション
    $perPage = $request->integer('per_page', 15);
    $shops = $query->paginate($perPage);

    return ShopResource::collection($shops);
}

出力:

TEXT
// 実行成功

4. ファイルアップロード

(1) 基本的なアップロード処理

PHP
// ファイルアップロードのバリデーション
$validated = $request->validate([
    'logo' => 'required|image|mimes:jpeg,png,webp|max:2048',
]);

// ファイルの保存
$path = $request->file('logo')->store('shops/logos', 'public');
// => "shops/logos/abc123.jpg"

// カスタム名で保存
$path = $request->file('logo')->storeAs(
    'shops/logos',
    $shop->slug . '.' . $request->file('logo')->extension(),
    'public',
);

// ファイル情報の取得
$file = $request->file('logo');
$file->getClientOriginalName();
$file->getClientOriginalExtension();
$file->getSize();          // バイト
$file->getMimeType();
メソッド 説明
store() ランダムなファイル名で指定ディレクトリに保存
storeAs() カスタムファイル名で指定ディレクトリに保存
storePublicly() パブリックディレクトリに保存
isValid() アップロードが成功したか確認

(1) ▶ サンプル:ShopMetrics店舗ロゴのアップロード

PHP
// app/Http/Controllers/ShopLogoController.php
class ShopLogoController extends Controller
{
    public function update(Request $request, Shop $shop): RedirectResponse
    {
        $validated = $request->validate([
            'logo' => 'required|image|mimes:jpeg,png,webp|max:2048',
        ]);

        // 古いロゴがあれば削除
        if ($shop->logo_path) {
            Storage::disk('public')->delete($shop->logo_path);
        }

        // 新しいロゴを保存
        $path = $request->file('logo')->store("shops/{$shop->id}/logos", 'public');

        $shop->update(['logo_path' => $path]);

        return back()->with('success', 'Logo updated.');
    }
}

出力:

TEXT
// 実行成功

5. レスポンスビルダー

(1) レスポンスタイプ

PHP
// JSONレスポンス
return response()->json(['message' => 'Created', 'data' => $shop], 201);

// ビューレスポンス
return response()->view('shops.show', compact('shop'), 200);

// リダイレクト
return redirect()->route('shops.show', $shop);
return back()->withInput()->withErrors($errors);
return redirect()->away('https://external-site.com');

// ファイルダウンロード
return response()->download(storage_path('app/reports/report.pdf'));
return response()->streamDownload(function () {
    echo generateCsv();
}, 'orders.csv', ['Content-Type' => 'text/csv']);

// コンテンツなし
return response()->noContent(); // 204
レスポンスタイプ メソッド HTTPステータスコード
JSON response()->json() 200/201/422
View response()->view() 200
リダイレクト redirect() 302
ダウンロード response()->download() 200
ストリームダウンロード response()->streamDownload() 200
コンテンツなし response()->noContent() 204

(1) ▶ サンプル:ShopMetrics CSVエクスポートストリーミングレスポンス

PHP
// app/Http/Controllers/ExportController.php
class ExportController extends Controller
{
    public function exportOrders(Request $request, Shop $shop): StreamedResponse
    {
        $this->authorize('view', $shop);

        return response()->streamDownload(function () use ($shop) {
            $csv = fopen('php://output', 'w');
            fputcsv($csv, ['Order ID', 'Customer', 'Total', 'Status', 'Date']);

            $shop->orders()
                ->with('user')
                ->orderBy('created_at', 'desc')
                ->chunk(500, function ($orders) use ($csv) {
                    foreach ($orders as $order) {
                        fputcsv($csv, [
                            $order->order_number,
                            $order->user->name,
                            $order->total,
                            $order->status,
                            $order->created_at->format('Y-m-d'),
                        ]);
                    }
                });

            fclose($csv);
        }, "orders-{$shop->slug}-" . now()->format('Y-m-d') . '.csv', [
            'Content-Type' => 'text/csv',
        ]);
    }
}

出力:

TEXT
// 実行成功

6. APIページネーション

(1) ページネーション方法の比較

方法 クエリ数 返却情報 使用場面
paginate() 2 (COUNT+SELECT) total/last_page/links 総ページ数が必要
simplePaginate() 1 (SELECT) next/prevリンク 総ページ数不要
cursorPaginate() 1 (SELECT+WHERE) next/prevカーソル 大規模データセット

(2) JSON形式のページネーション

JSON
{
    "data": [
        {"id": 1, "name": "Alice Store"},
        {"id": 2, "name": "Bob Electronics"}
    ],
    "current_page": 1,
    "per_page": 15,
    "total": 50,
    "last_page": 4,
    "from": 1,
    "to": 15,
    "links": {
        "first": "/api/v1/shops?page=1",
        "last": "/api/v1/shops?page=4",
        "next": "/api/v1/shops?page=2",
        "prev": null
    }
}

(3) カーソルページネーション

PHP
// 大規模データセットでより効率的
$shops = Shop::cursorPaginate(15);
// "page"の代わりに"cursor"パラメータを使用
// URL: /api/shops?cursor=eyJpZCI6MTV9

// 次ページURL
$shops->nextPageUrl();
// /api/shops?cursor=eyJpZCI6MzB9

(1) ▶ サンプル:ShopMetrics APIページネーションとフィルタリング

PHP
// app/Http/Controllers/Api/OrderController.php
public function index(Request $request): JsonResponse
{
    $query = Order::where('tenant_id', tenant()->id)
        ->with(['shop', 'user', 'items.product']);

    // フィルター
    if ($request->filled('status')) {
        $query->where('status', $request->status);
    }
    if ($request->filled('shop_id')) {
        $query->where('shop_id', $request->shop_id);
    }
    if ($request->filled('date_from')) {
        $query->where('created_at', '>=', $request->date('date_from'));
    }
    if ($request->filled('date_to')) {
        $query->where('created_at', '<=', $request->date('date_to'));
    }

    // ソート
    $query->orderBy(
        $request->input('sort_by', 'created_at'),
        $request->input('sort_dir', 'desc'),
    );

    // ページネーション戦略の選択
    $perPage = $request->integer('per_page', 15);

    $orders = $request->boolean('cursor', false)
        ? $query->cursorPaginate($perPage)
        : $query->paginate($perPage);

    return OrderResource::collection($orders);
}

出力:

TEXT
// 実行成功

7. レスポンスマクロとフォーマット標準化

(1) レスポンスマクロの定義

PHP
// app/Providers/AppServiceProvider.php
public function boot(): void
{
    Response::macro('apiSuccess', function (mixed $data, string $message = 'Success', int $status = 200) {
        return response()->json([
            'success' => true,
            'message' => $message,
            'data' => $data,
        ], $status);
    });

    Response::macro('apiError', function (string $message, int $status = 400, array $errors = []) {
        return response()->json([
            'success' => false,
            'message' => $message,
            'errors' => $errors,
        ], $status);
    });
}

(2) レスポンスマクロの使用

PHP
// コントローラ内
return response()->apiSuccess($shop, 'Shop created', 201);
return response()->apiError('Shop not found', 404);
return response()->apiError('Validation failed', 422, $validator->errors()->toArray());

(1) ▶ サンプル:ShopMetrics API標準レスポンスフォーマット

PHP
// app/Traits/ApiResponse.php
trait ApiResponse
{
    protected function success(mixed $data, string $message = 'Success', int $status = 200): JsonResponse
    {
        return response()->json([
            'success' => true,
            'message' => $message,
            'data' => $data,
            'timestamp' => now()->toISOString(),
        ], $status);
    }

    protected function error(string $message, int $status = 400, array $errors = []): JsonResponse
    {
        return response()->json([
            'success' => false,
            'message' => $message,
            'errors' => $errors,
            'timestamp' => now()->toISOString(),
        ], $status);
    }

    protected function paginated($resource, string $message = 'Success'): JsonResponse
    {
        return response()->json([
            'success' => true,
            'message' => $message,
            'data' => $resource->resolve(),
            'meta' => [
                'current_page' => $resource->resource->currentPage(),
                'per_page' => $resource->resource->perPage(),
                'total' => $resource->resource->total(),
                'last_page' => $resource->resource->lastPage(),
            ],
            'links' => [
                'first' => $resource->resource->url(1),
                'last' => $resource->resource->url($resource->resource->lastPage()),
                'next' => $resource->resource->nextPageUrl(),
                'prev' => $resource->resource->previousPageUrl(),
            ],
        ]);
    }
}

出力:

TEXT
// 実行成功

8. 総合例:ShopMetrics APIリクエスト処理の完全フロー

PHP
// ============================================
// 総合:ShopMetrics APIリクエスト・レスポンス
// カバー:入力処理, ファイルアップロード, ページネーション, レスポンスフォーマット
// ============================================

// app/Http/Controllers/Api/ProductController.php
class ProductController extends Controller
{
    use ApiResponse;

    public function index(Request $request): JsonResponse
    {
        $query = Product::where('shop_id', $request->shop_id)
            ->with('categories');

        if ($request->filled('search')) {
            $query->where('name', 'like', "%{$request->search}%");
        }
        if ($request->filled('category')) {
            $query->whereHas('categories', fn ($q) => $q->where('slug', $request->category));
        }
        if ($request->filled('min_price')) {
            $query->where('price', '>=', $request->float('min_price'));
        }
        if ($request->filled('max_price')) {
            $query->where('price', '<=', $request->float('max_price'));
        }
        if ($request->filled('in_stock') && $request->boolean('in_stock')) {
            $query->where('stock', '>', 0);
        }

        $query->orderBy(
            $request->input('sort', 'created_at'),
            $request->input('direction', 'desc'),
        );

        $products = $query->paginate($request->integer('per_page', 15));

        return $this->paginated(ProductResource::collection($products));
    }

    public function store(StoreProductRequest $request): JsonResponse
    {
        $product = Product::create($request->validated());

        if ($request->hasFile('image')) {
            $path = $request->file('image')->store("products/{$product->id}", 's3');
            $product->update(['image_path' => $path]);
        }

        return $this->success(new ProductResource($product), 'Product created', 201);
    }

    public function export(Request $request, Shop $shop): StreamedResponse
    {
        $this->authorize('view', $shop);

        return response()->streamDownload(function () use ($shop) {
            echo ProductExporter::toCsv($shop);
        }, "products-{$shop->slug}.csv", ['Content-Type' => 'text/csv']);
    }
}

❓ よくある質問

Q input()query()の違いは何ですか?
A input()はすべてのソース (クエリ文字列 + リクエストボディ)からデータを取得し, query()はURLクエリ文字列からのみデータを取得します。POSTフォームデータにはinput(), GETパラメータにはquery()を使うとより明確です。
Q cursorPaginatepaginateはどう使い分ければよいですか?
A 10万件未満のデータセットではpaginateを使用 (総ページ数の表示が必要), 10万件以上のデータセットではcursorPaginateを使用 (COUNTクエリ不要, パフォーマンス向上)。無限スクロールUIにはcursorが最適です。
Q APIレスポンスフォーマットはどう標準化すべきですか?
A ApiResponseトレイトまたはレスポンスマクロを作成し, success, error, paginatedメソッドをカプセル化してください。すべてのコントローラでこれらの標準化されたレスポンスメソッドを使用し, フィールド名, ステータスコード, ページネーションフォーマットの一貫性を確保してください。
Q 大きなファイルのダウンロードにはdownload()streamDownload()のどちらを使うべきですか?
A 小さなファイルはdownload()でメモリに直接読み込みます。大きなファイルはstreamDownload()でストリーミングし, メモリ使用量を一定に保ちます。CSVエクスポートなどのシナリオではstreamDownload()を使用してください。
Q APIバージョン間のレスポンスフォーマットの違いはどう処理しますか?
A 各APIバージョンで別のResourceクラスを使用します (V1/ShopResource vs. V2/ShopResource)。出力フィールドはResourceのtoArray()メソッドで制御し, ルーティング層で適切なバージョンを選択します。
Q アップロードファイルのローカル保存とS3保存の違いは何ですか?
A ローカル保存はサーバーのディスクに保存 (無料だが拡張性なし), S3はクラウドに保存 (従量課金, CDN加速, 無制限スケーリング)します。本番環境ではS3を推奨します。

📖 まとめ


📝 練習問題

  1. 基本問題 (⭐):ShopMetrics APIのGET /api/v1/shopsエンドポイントを実装し, search, status, sort, directionフィルタパラメータをサポートし, paginateでページ分割されたJSONを返してください。

  2. 応用問題 (⭐⭐):ApiResponseトレイトを作成し, success, error, paginatedメソッドをカプセル化し, すべてのAPIコントローラで使用してレスポンスフォーマットの一貫性を確保してください。

  3. チャレンジ (⭐⭐⭐):カーソル使用のページ分割注文一覧API (GET /api/v1/orders?cursor=xxx)を実装し, 10万件のレコードを処理する際のpaginatecursorPaginateのクエリパフォーマンスの違いを比較してください。

Web-Tutorial.com

Web-Tutorial 技術チーム

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

100%