Laravelで構築するRESTful API:基本設計から高度な実践例まで(壊れにくいAPIの作り方)

Laravelで構築するRESTful API:基本設計から高度な実践例まで(壊れにくいAPIの作り方)

LaravelでRESTful APIを作るときは「動くものを早く作る」より「壊れないルールを最初に固める」方が結果的に速い。ポイントは、(1) リソース設計(URI/HTTPメソッド/ステータスコード)、(2) バリデーションとエラー形式の統一、(3) 認証・認可、(4) ページングとフィルタ、(5) トランザクションと同時実行、(6) パフォーマンスと監視、の順で決めること。これを押さえると、後から機能を増やしても破綻しない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で衝突を見える化し、上書き事故を止める
・トランザクションとジョブで副作用を分離し、整合性を守る