Laravelで構築するRESTful API:基本設計から高度な実践例まで(壊れにくいAPIの作り方)
- 作成日 2026.02.05
- その他
LaravelでRESTful APIを作るときは「動くものを早く作る」より「壊れないルールを最初に固める」方が結果的に速い。ポイントは、(1) リソース設計(URI/HTTPメソッド/ステータスコード)、(2) バリデーションとエラー形式の統一、(3) 認証・認可、(4) ページングとフィルタ、(5) トランザクションと同時実行、(6) パフォーマンスと監視、の順で決めること。これを押さえると、後から機能を増やしても破綻しないAPIになる。
- 1. REST設計の基本:URI・メソッド・ステータスを固定する
- 2. 発生条件:APIがすぐに崩れる典型パターン
- 3. ルーティング:apiResourceで基本形を揃える
- 4. コントローラ設計:RequestとResourceで責務を分ける
- 5. バリデーション:FormRequestで422を安定させる
- 6. 返却形式:API Resourceでレスポンスを固定する
- 7. 認証・認可:認証はミドルウェア、認可はPolicyで閉じる
- 8. ページングとフィルタ:per_page上限・ソート固定・深いoffsetの対策
- 9. 高度な実践:同時更新の上書き事故を防ぐ(楽観ロックの考え方)
- 10. 高度な実践:トランザクションと副作用の分離(DB更新+通知など)
- 11. パフォーマンス:N+1、不要列、巨大IN、重い集計を避ける
- 12. エラー形式を統一する:例外を握りつぶさず“APIの形”で返す
- 13. まとめ:RESTful APIを“運用できる形”にする最短ルート
REST設計の基本:URI・メソッド・ステータスを固定する
基本は「名詞(複数形)でリソース」「状態の変更はHTTPメソッド」「結果はステータスコード」。
・GET /api/posts … 一覧
・GET /api/posts/{id} … 詳細
・POST /api/posts … 作成
・PUT/PATCH /api/posts/{id} … 更新
・DELETE /api/posts/{id} … 削除
ステータスの例:
・200 OK(取得/更新成功)
・201 Created(作成成功)
・204 No Content(削除成功でボディ不要)
・400 Bad Request(形式不正)
・401 Unauthorized(未認証)
・403 Forbidden(権限不足)
・404 Not Found(存在しない)
・422 Unprocessable Entity(バリデーションエラー)
・409 Conflict(競合、楽観ロック失敗など)
・500 Internal Server Error(サーバ側例外)
発生条件:APIがすぐに崩れる典型パターン
・バリデーションエラーの形式がエンドポイントごとにバラバラ
・例外がそのまま返り、フロント側が処理できない
・PUTとPATCHの扱いが混在し、部分更新で意図せぬ上書きが起きる
・一覧APIが全件返してタイムアウト、もしくは巨大レスポンスになる
・認可が抜けて「ログインしていれば誰でも更新できる」状態になる
・N+1や不要な列取得で、データ量が増えるほど遅くなる
・同時更新で上書き事故が起きる(楽観ロック/排他が無い)
ルーティング:apiResourceで基本形を揃える
RESTの形はLaravelに任せた方がブレない。
// routes/api.php
use App\Http\Controllers\Api\PostController;
use Illuminate\Support\Facades\Route;
Route::apiResource('posts', PostController::class);特定アクションだけ公開したいなら only/except で絞る。
Route::apiResource('posts', PostController::class)->only(['index', 'show']);
コントローラ設計:RequestとResourceで責務を分ける
コントローラに「バリデーション」「DB操作」「レスポンス整形」を詰め込むと破綻する。
・バリデーション:FormRequest
・返却形式:API Resource(JsonResource)
・クエリ組み立て:Query Builder / スコープ
・更新処理:トランザクション + サービス(必要なら)
Route::apiResource('posts', PostController::class)->only(['index', 'show']);
// app/Http/Controllers/Api/PostController.php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\PostStoreRequest;
use App\Http\Requests\PostUpdateRequest;
use App\Http\Resources\PostResource;
use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class PostController extends Controller
{
public function index(Request $request)
{
$query = Post::query()
->select(['id', 'user_id', 'title', 'status', 'published_at'])
->with(['user:id,name']);
// フィルタ例
if ($request->filled('status')) {
$query->where('status', $request->string('status'));
}
// 検索例(雑なORでインデックスを潰しやすいので、実務では設計を揃える)
if ($request->filled('q')) {
$kw = $request->string('q');
$query->where(function ($w) use ($kw) {
$w->where('title', 'like', "%{$kw}%")
->orWhere('body', 'like', "%{$kw}%");
});
}
$perPage = min((int) $request->input('per_page', 20), 100);
return PostResource::collection(
$query->orderByDesc('published_at')->paginate($perPage)
);
}
public function show(Post $post)
{
$post->load(['user:id,name']);
return new PostResource($post);
}
public function store(PostStoreRequest $request)
{
$post = DB::transaction(function () use ($request) {
return Post::create([
'user_id' => $request->user()->id,
'title' => $request->string('title'),
'body' => $request->string('body'),
'status' => $request->string('status', 'draft'),
'published_at' => $request->input('published_at'),
]);
});
return (new PostResource($post))
->response()
->setStatusCode(201);
}
public function update(PostUpdateRequest $request, Post $post)
{
DB::transaction(function () use ($request, $post) {
$post->fill($request->validated());
$post->save();
});
return new PostResource($post->fresh()->load(['user:id,name']));
}
public function destroy(Post $post)
{
$post->delete();
return response()->noContent();
}
}バリデーション:FormRequestで422を安定させる
APIが一番壊れやすいのは入力。FormRequestに寄せると「必ず422」で返る形を揃えやすい。
// app/Http/Requests/PostStoreRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class PostStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true; // 認可はPolicyで別途やる構成が多い
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:200'],
'body' => ['required', 'string'],
'status' => ['nullable', 'in:draft,public,private'],
'published_at' => ['nullable', 'date'],
];
}
}部分更新(PATCH)を許すなら、UpdateRequest側は required を外し、filled/nullableで制御する。
返却形式:API Resourceでレスポンスを固定する
レスポンスの揺れはフロントの地獄になる。Resourceで「返す形」を固定する。
// app/Http/Resources/PostResource.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PostResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'status' => $this->status,
'published_at' => $this->published_at,
'user' => $this->whenLoaded('user', function () {
return [
'id' => $this->user->id,
'name' => $this->user->name,
];
}),
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}認証・認可:認証はミドルウェア、認可はPolicyで閉じる
発生条件:認証だけ付けて、更新や削除の権限チェックが抜ける。
・認証:authミドルウェア
・認可:Policy(update/deleteなど)
// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('posts', \App\Http\Controllers\Api\PostController::class);
});
// app/Policies/PostPolicy.php(例)
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
// Controller側(例)
public function update(PostUpdateRequest $request, Post $post)
{
$this->authorize('update', $post);
// 更新処理...
}ページングとフィルタ:per_page上限・ソート固定・深いoffsetの対策
発生条件:per_page無制限、ソート条件が任意、巨大レスポンスで遅くなる。
・per_pageは上限(例:100)
・orderByは許可リスト(勝手なカラム指定を通さない)
・無限スクロールならcursorPaginateを検討
// cursorPaginate例(次へ次へ型に向く)
$posts = Post::query()
->where('status', 'public')
->orderByDesc('published_at')
->cursorPaginate(20);高度な実践:同時更新の上書き事故を防ぐ(楽観ロックの考え方)
発生条件:Aさんが編集開始→Bさんが先に保存→Aさんが古い内容で保存→Bさんの変更が消える。
対処の一例:更新時にupdated_atを条件に入れて「一致しないなら409」。
// 更新時にクライアントから "updated_at" を受け取り、条件に含める例
$affected = \App\Models\Post::query()
->whereKey($post->id)
->where('updated_at', $request->input('updated_at'))
->update([
'title' => $request->input('title'),
'body' => $request->input('body'),
]);
if ($affected === 0) {
return response()->json([
'message' => 'conflict',
'detail' => 'The resource was updated by another request.'
], 409);
}設計としては「ETag / If-Match」などHTTP標準に寄せる手もあるが、まずは409で衝突を見える化すると事故が止まる。
高度な実践:トランザクションと副作用の分離(DB更新+通知など)
発生条件:DB更新は成功したのに通知だけ失敗、またはその逆で整合性が崩れる。
対処:DB更新はトランザクション、外部通知はコミット後にジョブへ。
DB::transaction(function () use ($data, &$post) {
$post = Post::create($data);
});
dispatch(new \App\Jobs\NotifyPostCreatedJob($post->id));
パフォーマンス:N+1、不要列、巨大IN、重い集計を避ける
発生条件:一覧で関連参照、select *、集計をループ内で繰り返す。
・withでN+1回避
・selectで列削減
・withCountで件数をまとめる
・大量データはchunk/cursor
// withCountで関連件数をまとめる
$posts = Post::query()
->select(['id', 'user_id', 'title'])
->with('user:id,name')
->withCount('comments')
->latest()
->paginate(20);エラー形式を統一する:例外を握りつぶさず“APIの形”で返す
発生条件:例外がHTMLで返る、環境差でレスポンスが変わる、フロントがパース不能。
・バリデーションは422に統一(FormRequest)
・認可は403、未認証は401
・想定外は500(ログで追う)
・ユニーク制約などは409/422へ寄せる(要件次第)
実務では「message」「errors」「code」「trace_id」などのキーを固定しておくと運用が楽になる。
まとめ:RESTful APIを“運用できる形”にする最短ルート
・apiResourceでルートを固定し、URIとメソッドのブレを消す
・FormRequestで入力を閉じ、422の形式を安定させる
・Resourceで返却形式を固定し、フロントの分岐を減らす
・認証はミドルウェア、認可はPolicyで閉じる
・ページング上限と許可ソートで負荷を制御する
・同時更新は409で衝突を見える化し、上書き事故を止める
・トランザクションとジョブで副作用を分離し、整合性を守る
-
前の記事
Laravel Mixを使ったフロントエンドビルドプロセス最適化:遅い・重い・壊れるを減らす実務設定 2026.02.04
-
次の記事
記事がありません
コメントを書く