Laravelで堅牢なWeb APIを設計する:Sanctum認証、OpenAPI、ETag、レート制限の実務

php elephant sticker
Photo by RealToughCandy.com on Pexels.com

Web APIは、サーバーの機能やデータをHTTP経由でほかのプログラムから利用するための窓口です。

LaravelでAPIを実装するときは、URLやJSONの形だけでなく、誰を認証し、何を許可し、変更や失敗をクライアントへどう伝えるかまで決めます。

堅牢さは機能の多さでは決まりません。

契約を明文化し、入力検証、認可、互換性、負荷対策、監視、テストを同じ方針でつなげることで、公開後に直しやすいAPIになります。

この記事で扱う範囲

  • リソース指向のURL、HTTPメソッド、ステータスコード
  • Laravel SanctumによるSPAとAPIトークンの認証
  • FormRequest、Policy、Eloquent API Resourcesの役割分担
  • URLバージョニングとOpenAPIによる契約管理
  • エラー形式、ETag、レート制限、冪等性、CORS
  • 人が理解しやすい開発者向け文書と検証項目

例は投稿を扱うAPIを題材にしています。

認証方式や保存先などは、実際のLaravelバージョンと業務要件に合わせて調整してください。

実装前に固定するAPI契約

API契約とは、クライアントとサーバーの間で変えないと約束する入出力と動作です。

URL、メソッド、JSONスキーマだけでなく、認証、エラー、互換性、再試行条件も契約に含まれます。

API実装前に合意する項目
領域 先に決めること 確認方法
リソース URL、HTTPメソッド、識別子、一覧の絞り込み方 代表的なリクエスト例をレビューする
本人確認 Cookie認証かAPIトークンか、トークンの失効方法 未認証、失効済み、権限不足を個別に試す
データ 必須項目、型、日時形式、nullの扱い 入力検証とレスポンスのスキーマをテストする
失敗 ステータスコード、エラーコード、利用者向け文、追跡ID 主要な失敗例を仕様書に載せる
変更 互換性の判断基準、廃止予告、旧版の終了条件 OpenAPI差分と契約テストをCIで確認する
運用 レート制限、キャッシュ、監査ログ、個人情報の除外 負荷試験とログレビューを行う

リソースをURLとHTTPメソッドへ割り当てる

URLは処理名ではなく、操作対象の名詞で表すと役割を追いやすくなります。

  • GET /api/v1/posts:投稿の一覧を取得する
  • POST /api/v1/posts:投稿を作成する
  • GET /api/v1/posts/{post}:一件の投稿を取得する
  • PATCH /api/v1/posts/{post}:投稿の一部を更新する
  • DELETE /api/v1/posts/{post}:投稿を削除する
  • GET /api/v1/posts/{post}/comments:投稿に属するコメントを取得する

URL表記の規則は、複数形にするか、単語をハイフンでつなぐかまでチームで統一します。

一覧APIでは、ページ番号、件数、並び順、絞り込み条件を公開仕様として定義し、並び替え対象の列はサーバー側の許可リストへ変換します。

GET /api/v1/posts?page=2&per_page=20&sort=-created_at&author_id=123

リクエスト値をそのままorderByへ渡すのではなく、-created_atを「作成日時の降順」のような既知の処理へ対応付けます。

ステータスコードの使い分け

同じ失敗を毎回同じステータスとエラー形式で返すと、クライアントは分岐を安定して実装できます。

  • 200 OK:取得またはレスポンス本文を伴う更新に成功した
  • 201 Created:作成に成功し、必要に応じてLocationで作成先を示す
  • 204 No Content:削除などに成功し、返す本文がない
  • 400 Bad Request:JSONの構文など、リクエスト自体を解釈できない
  • 401 Unauthorized:認証情報がないか、有効な認証が成立していない
  • 403 Forbidden:認証は成立したが、その操作を許可できない
  • 404 Not Found:対象がないか、対象の存在を公開しない
  • 409 Conflict:現在の状態と競合し、そのままでは処理できない
  • 422 Unprocessable Content:JSONは解釈できたが、入力値が検証規則を満たさない
  • 429 Too Many Requests:定めた回数制限を超えた

LaravelはJSONレスポンスを期待するリクエストで入力検証に失敗すると、既定で422のJSON応答を返します。

403と404の選択は、他人のリソースが存在すること自体を隠す必要があるかまで含めて決めます。

ルート、入力検証、認可を分離する

現行Laravelの新規構成では、APIルートとSanctumを有効にする入口として次のコマンドが案内されています。

php artisan install:api

既存プロジェクトでは依存パッケージと設定がすでに存在する場合があるため、実行前にLaravelのバージョンと現在の構成を確認します。

ルートは保護条件を読み取れる形にする

// routes/api.php
use App\Http\Controllers\Api\V1\PostController;
use Illuminate\Support\Facades\Route;

Route::prefix('v1')
    ->middleware(['auth:sanctum', 'throttle:api'])
    ->group(function (): void {
        Route::apiResource('posts', PostController::class);
    });

routes/api.phpには既定で/apiの接頭辞が付くため、この例のURLは/api/v1/postsになります。

auth:sanctumは認証を、throttle:apiは名前付きのレート制限を担当します。

FormRequestは入力の境界を守る

use Illuminate\Foundation\Http\FormRequest;

final class PostStoreRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:120'],
            'body' => ['required', 'string'],
            'tags' => ['sometimes', 'array'],
            'tags.*' => ['string', 'max:30'],
        ];
    }

    public function attributes(): array
    {
        return [
            'title' => 'タイトル',
            'body' => '本文',
        ];
    }
}

sometimesは、その項目が送られた場合だけ後続の規則を適用する指定です。

作成と更新で必須条件が異なるなら、同じFormRequestへ条件分岐を増やすより、役割ごとに分けたほうが変更を追いやすくなります。

Policyは利用者と対象の関係を判定する

use App\Models\Post;
use App\Models\User;

final class PostPolicy
{
    public function update(User $user, Post $post): bool
    {
        return $post->user_id === $user->id;
    }
}

コントローラーでは$this->authorize('update', $post)を呼び、入力検証とは別に操作権限を確かめます。

Sanctumの設定やPolicy、Gateの整理は、関連記事のLaravelの認証と認可の実装ガイドも参照してください。

SanctumでSPAとAPIトークンを使い分ける

認証は利用者が誰かを確かめる処理で、認可はその利用者に操作を許すかを判定する処理です。

二つを分けて考えないと、有効なトークンを持つだけで他人のデータまで変更できる設計になりかねません。

Sanctumで扱う二つの認証方式
利用者 主な認証方式 設計上の注意
自社のSPA LaravelのセッションCookie SPAとAPIは同じトップレベルドメインを共有し、CSRFとCookieの設定を整える
モバイルアプリまたは外部クライアント Bearer形式のAPIトークン 用途を示す名前、必要最小限のAbility、失効手段を用意する

自社SPAでは、長期トークンをブラウザーへ保存する方式ではなく、SanctumのCookieベースのSPA認証を使います。

別サブドメインで動かす場合は、Sanctumのstatefulドメイン、Cookie属性、CORS、HTTPクライアントの資格情報送信を一組として確認します。

APIトークンには、実行可能な操作をAbilityとして割り当てられます。

$plainTextToken = $user
    ->createToken('reporting-cli', ['posts:read', 'posts:update'])
    ->plainTextToken;

平文トークンは作成直後に安全な経路で一度だけ渡し、ログや通常のレスポンスへ残しません。

なお、SanctumのCookieで認証した自社SPAからの要求ではtokenCan()が常にtrueを返すため、Abilityの確認だけを認可の根拠にはできません。

Policyで所有者、役割、対象の状態を確かめ、APIトークン利用時のAbilityと組み合わせます。

API Resourcesでレスポンスを固定する

Eloquent API Resourcesは、モデルを外部公開用の配列へ変換する層です。

モデルの列や関連をそのまま返さず、公開する項目と形式をResourceに閉じ込めることで、内部変更がAPIへ漏れる範囲を抑えられます。

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

final class PostResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => (string) $this->id,
            'title' => $this->title,
            'body' => $this->body,
            'author' => [
                'id' => (string) $this->author->id,
                'name' => $this->author->name,
            ],
            'tags' => $this->tags->pluck('name')->values(),
            'created_at' => $this->created_at->toIso8601String(),
        ];
    }
}

このResourceはauthorとtagsを参照するため、一覧取得では先に関連を読み込みます。

$posts = Post::query()
    ->with(['author', 'tags'])
    ->latest()
    ->paginate(20);

return PostResource::collection($posts);

日時のタイムゾーン、IDを文字列にするか数値にするか、関連が未読込の場合の扱いも契約として固定します。

破壊的変更をバージョンで隔離する

破壊的変更は、既存クライアントが同じ使い方を続けられなくなる変更です。

フィールドの削除や改名、型や意味の変更、任意入力の必須化、認証方式の変更は、原則として新しいメジャーバージョンへ分けます。

互換性を判断する例
変更例 主な扱い 確認点
titleを削除する 破壊的変更 新バージョンへ分け、移行期限を示す
idを数値から文字列へ変える 破壊的変更 生成コードと既存クライアントの型を確認する
任意のレスポンス項目を追加する 互換にできる場合がある 未知の項目を拒否するクライアントがないか確かめる
説明文だけを明確にする 通常は互換 値の意味や制約まで変えていないか確かめる

URL方式なら/api/v1と/api/v2を分け、コントローラーもApi/V1とApi/V2へ分離します。

一方、注文計算などバージョンに依存しない業務処理は共有サービスへ置き、HTTP表現の違いと混ぜません。

旧版には廃止予定日、代替先、移行手順を示し、アクセスログから残存クライアントを確認して終了判断を行います。

OpenAPIをレビューとテストの基準にする

OpenAPIは、HTTP APIの操作、入力、応答、認証などを機械処理できる形式で記述する仕様です。

仕様ファイルを置くだけでは実装とのずれを防げないため、変更レビュー、スキーマ検証、契約テストへ同じファイルを使います。

次は投稿作成操作に必要な部分を抜き出した例です。

openapi: 3.0.3
info:
  title: Posts API
  version: 1.0.0
paths:
  /api/v1/posts:
    post:
      operationId: createPost
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title, body]
              properties:
                title:
                  type: string
                  maxLength: 120
                body:
                  type: string
      responses:
        '201':
          description: Created
        '422':
          description: Validation error
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer

実装では、成功例だけでなく、401、403、404、422、429のレスポンス例も定義します。

型付きクライアントやモックを作る場合でも、生成結果を無条件に正しいとみなさず、実際の応答との契約テストを残します。

エラーを利用者の次の操作につなげる

エラー応答は、人向けの説明と、プログラムが分岐に使う安定した識別子を分けます。

{
  "code": "validation_error",
  "message": "入力内容を確認してください。",
  "errors": {
    "title": ["タイトルは必須です。"]
  },
  "request_id": "example-request-id"
}
  • code:クライアントが分岐に使う、変更しにくい識別子
  • message:何が起きたかを短く示す説明
  • errors:入力項目ごとの問題と修正方法
  • request_id:利用者からの問い合わせとサーバーログを結び付ける値

「不正です」だけでは、利用者は修正できません。

「タイトルは120文字以内で入力してください」のように、受け付けられなかった項目と直し方を示します。

ただし、認証失敗の理由、内部例外、SQL、スタックトレースを公開すると攻撃の手掛かりや情報漏えいになるため、公開文と運用ログを分けます。

Web画面でAPIのエラーを表示する場合、色だけで失敗を示さず、該当項目をテキストで特定して修正案を伝えます。

これはWCAG 2.2の「エラーの特定」と「エラー修正の提案」に沿う考え方です。

Laravelの入力検証を画面へ結び付ける方法は、FormRequestとアクセシブルなエラー表示の実装で詳しく解説しています。

ETagで変更がない応答を再利用する

ETagは、あるURLから返す表現の版を識別するHTTPヘッダーです。

クライアントが前回のETagをIf-None-Matchで送ると、表現が変わっていない場合はサーバーが本文なしの304を返せます。

use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

public function show(Post $post, Request $request): Response
{
    $response = (new PostResource($post->load(['author', 'tags'])))
        ->response();

    $response->setPrivate();
    $response->headers->addCacheControlDirective('no-cache');
    $response->setEtag(hash('sha256', (string) $response->getContent()));
    $response->isNotModified($request);

    return $response;
}

この例のprivateは共有キャッシュで再利用させない指定で、no-cacheは再利用前の検証を求める指定です。

認証済み利用者ごとに内容が変わる応答へ安易にpublicを付けると、共有キャッシュを通じて別の利用者へ内容が渡る危険があります。

ETagは実際に返すJSONから計算し、言語や権限で表現が変わるなら、その差をキャッシュキーとVaryの設計へ反映します。

Last-Modifiedを使う場合も、同じURLのどの変更時刻を表すかを決め、条件付きGETのテストを行います。

LaravelのアプリケーションキャッシュとHTTPキャッシュを整理したい場合は、Laravelのキャッシュ戦略も役立ちます。

レート制限、冪等性、CORSを運用条件に合わせる

レート制限は利用者と処理負荷で分ける

レート制限は、一定時間内に受け付ける操作回数を制御する仕組みです。

Laravel 13.xの例では、名前付き制限をAppServiceProviderのbootで定義し、ルートへthrottleミドルウェアとして付けます。

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

public function boot(): void
{
    RateLimiter::for('api', function (Request $request) {
        return Limit::perMinute(60)
            ->by($request->user()?->id ?: $request->ip());
    });
}

60回という数値は実装例であり、すべてのAPIに適した推奨値ではありません。

ログイン、検索、ファイル作成のように負荷と悪用リスクが違う操作は、別の制限名とキーを使います。

429応答には、クライアントが待機や再試行を実装できる情報を含め、その意味を仕様書に記載します。

短時間の集中を抑えるレート制限と、日次または月次の総量を抑えるクォータの違いは、Laravelのレート制限とクォータの設計で整理しています。

Idempotency-Keyは二重実行を避ける契約にする

冪等性は、同じ操作を複数回受けても、意図した結果が一度分に保たれる性質です。

通信切断後の再送で注文や決済などが重複すると困るPOSTでは、クライアントがIdempotency-Keyを送る契約を検討します。

  1. 利用者と操作の組み合わせに対してキーを一意に予約する
  2. 同じキーで届いたリクエスト本文が同一か照合する
  3. 最初の処理結果を保存し、同一の再送にはその結果を返す
  4. 保存期間、処理中の応答、異なる本文でキーが再利用された場合の応答を仕様化する

メモリ上で存在確認してから保存するだけでは同時要求を防げないため、データベースの一意制約や原子的なロックを使って競合を処理します。

CORSは認証の代わりにしない

CORSは、別のオリジンで動くブラウザー上のコードへ応答を読ませるかを制御する仕組みです。

CORSを許可してもサーバー側の認証と認可は省けず、CORSで拒否してもサーバー間の直接要求を止める認証にはなりません。

  • 本番環境では許可するオリジン、メソッド、ヘッダーを必要な範囲に限定する
  • Cookieなどの資格情報を送る場合は、許可オリジンに*を使わず明示する
  • 要求元に応じて許可オリジンを返す場合は、Vary: Originを付ける
  • プリフライト要求を含めて、実際に使うブラウザーから検証する

監査ログと開発者向け文書を設計する

障害調査に必要な情報は、後から推測するのではなく、リクエスト単位で追える形にします。

  • リクエストID、認証済み利用者の内部ID、ルート名、結果のステータス、処理時間を構造化して記録する
  • 認可拒否、レート制限、外部サービスの失敗を区別する
  • APIトークン、パスワード、Cookie、不要な個人情報、本文全体をログへ残さない
  • 利用者向けのrequest_idから、運用担当者が該当ログを検索できるようにする

開発者向け文書では、各エンドポイントを「目的、認証、入力、成功例、失敗例、再試行条件、注意点」の順でそろえます。

色分けだけに意味を持たせず、コード例の前後に何を確認する例かを文章で示します。

文書サイトには内容に合ったlang属性、論理的な見出し、キーボードで操作できるナビゲーションを用意します。

複数言語を提供する場合は、翻訳がある範囲と未翻訳時の扱いを明示し、存在しない言語版へ利用者を誘導しません。

失敗経路までテストする

成功例だけのテストでは、API契約のうち認証、入力検証、競合、再試行を確認できません。

use App\Models\User;
use Laravel\Sanctum\Sanctum;

public function test_post_creation_returns_201_and_location(): void
{
    Sanctum::actingAs(
        User::factory()->create(),
        ['posts:update']
    );

    $response = $this->postJson('/api/v1/posts', [
        'title' => 'API設計',
        'body' => '本文',
    ]);

    $response
        ->assertCreated()
        ->assertHeader('Location')
        ->assertJsonPath('data.title', 'API設計');
}
公開前に通す主なテスト
対象 確認する失敗または境界
認証 Cookieなし、トークンなし、失効済みトークン
認可 他人の投稿、読み取り専用Ability、非公開状態
入力 必須項目なし、上限超過、配列要素の型違い
一覧 最大件数、不正な並び順、ページ境界
条件付きGET ETag一致時の304、不一致時の200、利用者別応答
負荷制御 上限直前、上限超過、キーを分ける条件
冪等性 同一キーの同時要求、同一再送、異なる本文での再利用
契約 OpenAPIにない項目、型、ステータスの差分

公開前の運用チェックリスト

  • URL、メソッド、ステータス、JSON形式をOpenAPIと実装で一致させた
  • FormRequestとPolicyの両方を通し、認証と認可を分けた
  • APIトークンのAbility、失効、保管、ログ除外を決めた
  • 破壊的変更の基準、旧版の移行期間、終了手順を決めた
  • 認証済み応答のキャッシュ範囲とVaryを確認した
  • 操作ごとのレート制限と、429後のクライアント動作を確認した
  • 二重実行が困るPOSTに冪等性の契約と同時実行テストを用意した
  • CORSを実際のオリジンと資格情報設定で検証した
  • 利用者向けエラーからリクエストIDでログを追える
  • 文書に成功例と主要な失敗例があり、見出しと表を支援技術で読み取れる

Laravel API設計のまとめ

Laravelの機能を並べるだけでは、長く使えるAPIにはなりません。

URL、入力、認証、認可、レスポンス、エラーを一つの契約として定め、OpenAPIとテストで差分を検出できる状態にします。

そのうえで、ETag、レート制限、冪等性、CORS、監査ログを利用者と処理の性質に合わせて設計します。

最初に小さな投稿APIで成功経路と失敗経路を固め、同じ規則をほかのリソースへ展開すると、チーム内の判断をそろえやすくなります。

参考資料

この記事に関連する株式会社greedenの取り組み

APIは公開後の変更と運用まで見通して設計する必要があります。株式会社greedenは、Webシステム開発で要件定義からAPI、権限、例外処理、テスト、リリース後の保守改善まで一貫して支援します。

投稿者 greeden

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)