Laravel リクエストとレスポンス
リクエストとレスポンスはLaravelの「入出力」です。リクエストが来たときにデータを取り出し, レスポンスが出るときにデータを梱包します。どちらのプロセスも正確な制御が必要です。
1. 学ぶ内容
- リクエストオブジェクト:input/query/has/only/exceptと型変換
- ファイルアップロード処理:store/storedAs/S3連携
- レスポンスビルダー:json/view/download/redirect/stream
- APIリソースページネーション:LengthAwarePaginatorとカーソルページネーション
- レスポンスマクロとグローバルレスポンスフォーマットの標準化
2. API開発者からの実話
(1) ペインポイント:エンドポイントごとにデータフォーマットが違う
BobはShopMetrics APIに10のエンドポイントを書きましたが, あるものは{data: [...]}, あるものは{shops: [...]}, あるものは配列をそのまま返します。AliceがフロントエンドにAPIを統合する際, エンドポイントごとに異なる解析ロジックを書かなければなりませんでした。さらに困ったことに, ページネーションデータがtotal_pagesやlast_pageと異なる形式で返されるため, フロントエンドのページネーションコンポーネントを統一できませんでした。
(2) 標準化レスポンスのソリューション
LaravelのAPIリソースとレスポンスマクロが出力フォーマットを標準化します。すべてのエンドポイントが同じJSON構造を返し, ページネーションデータも一貫したフォーマットに従います。
// 標準化レスポンス — すべてのエンドポイントがこのフォーマットに従う
return ShopResource::collection($shops);
// {
// "data": [...],
// "meta": {"current_page": 1, "total": 50},
// "links": {"next": "...", "prev": "..."}
// }
(3) 成果
Aliceが統一フォーマットを採用した後, フロントエンド解析ロジックのセット数は10から1に減り, ページネーションコンポーネントの再利用率は100%に達しました。
3. リクエストオブジェクト
(1) 入力の取得
// 単一入力の取得
$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リクエストの処理
// 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);
}
出力:
// 実行成功
4. ファイルアップロード
(1) 基本的なアップロード処理
// ファイルアップロードのバリデーション
$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店舗ロゴのアップロード
// 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.');
}
}
出力:
// 実行成功
5. レスポンスビルダー
(1) レスポンスタイプ
// 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エクスポートストリーミングレスポンス
// 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',
]);
}
}
出力:
// 実行成功
6. APIページネーション
(1) ページネーション方法の比較
| 方法 | クエリ数 | 返却情報 | 使用場面 |
|---|---|---|---|
paginate() |
2 (COUNT+SELECT) | total/last_page/links | 総ページ数が必要 |
simplePaginate() |
1 (SELECT) | next/prevリンク | 総ページ数不要 |
cursorPaginate() |
1 (SELECT+WHERE) | next/prevカーソル | 大規模データセット |
(2) 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) カーソルページネーション
// 大規模データセットでより効率的
$shops = Shop::cursorPaginate(15);
// "page"の代わりに"cursor"パラメータを使用
// URL: /api/shops?cursor=eyJpZCI6MTV9
// 次ページURL
$shops->nextPageUrl();
// /api/shops?cursor=eyJpZCI6MzB9
(1) ▶ サンプル:ShopMetrics APIページネーションとフィルタリング
// 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);
}
出力:
// 実行成功
7. レスポンスマクロとフォーマット標準化
(1) レスポンスマクロの定義
// 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) レスポンスマクロの使用
// コントローラ内
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標準レスポンスフォーマット
// 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(),
],
]);
}
}
出力:
// 実行成功
8. 総合例:ShopMetrics APIリクエスト処理の完全フロー
// ============================================
// 総合: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']);
}
}
❓ よくある質問
input()とquery()の違いは何ですか?input()はすべてのソース (クエリ文字列 + リクエストボディ)からデータを取得し, query()はURLクエリ文字列からのみデータを取得します。POSTフォームデータにはinput(), GETパラメータにはquery()を使うとより明確です。cursorPaginateとpaginateはどう使い分ければよいですか?paginateを使用 (総ページ数の表示が必要), 10万件以上のデータセットではcursorPaginateを使用 (COUNTクエリ不要, パフォーマンス向上)。無限スクロールUIにはcursorが最適です。ApiResponseトレイトまたはレスポンスマクロを作成し, success, error, paginatedメソッドをカプセル化してください。すべてのコントローラでこれらの標準化されたレスポンスメソッドを使用し, フィールド名, ステータスコード, ページネーションフォーマットの一貫性を確保してください。download()とstreamDownload()のどちらを使うべきですか?download()でメモリに直接読み込みます。大きなファイルはstreamDownload()でストリーミングし, メモリ使用量を一定に保ちます。CSVエクスポートなどのシナリオではstreamDownload()を使用してください。📖 まとめ
- リクエストオブジェクトはinput, query, only, exceptなどのメソッドで入力を取得します
store()またはstoreAs()でアップロードファイルを指定ディスクに保存します- レスポンスはjson/view/redirect/download/streamの複数タイプに対応します
- APIのページネーションにはpaginate, simplePaginate, cursorPaginateの3つの戦略があります
- マクロで統一されたAPI出力フォーマットを遵守し, エンドポイントごとのフォーマット不一致を防ぎます
- 大きなファイルエクスポートには
streamDownloadでストリーミングし, メモリオーバーフローを防止します
📝 練習問題
-
基本問題 (⭐):ShopMetrics APIのGET /api/v1/shopsエンドポイントを実装し, search, status, sort, directionフィルタパラメータをサポートし, paginateでページ分割されたJSONを返してください。
-
応用問題 (⭐⭐):
ApiResponseトレイトを作成し,success,error,paginatedメソッドをカプセル化し, すべてのAPIコントローラで使用してレスポンスフォーマットの一貫性を確保してください。 -
チャレンジ (⭐⭐⭐):カーソル使用のページ分割注文一覧API (GET /api/v1/orders?cursor=xxx)を実装し, 10万件のレコードを処理する際の
paginateとcursorPaginateのクエリパフォーマンスの違いを比較してください。



