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

【実務完全ガイド】Laravelでつくる堅牢なWeb API設計――Sanctum認証・バージョニング・OpenAPI・ETag/キャッシュ・レート制限・アクセシブルなエラーデザイン

この記事で学べること(要点)

  • RESTful を土台にした API 設計指針(リソース設計、ステータスコード、エラーフォーマット)
  • Laravel Sanctum を使ったシンプルで安全な認証・認可(SPA/APIトークン/モバイル対応)
  • バージョニング(URL/ヘッダ/ルーティング分割)と後方互換の運用
  • Eloquent API Resources での安定したレスポンス整形、OpenAPI(Swagger)による仕様化
  • ETag・条件付きリクエスト・Cache-Control による帯域削減とスケール
  • レート制限・CORS・Idempotency-Key・監査ログなど、安全運用の必須装備
  • 開発者ドキュメントのアクセシビリティ配慮(言語・読み上げ・例の提示・エラー文の可読性)

想定読者(だれが得をする?)

  • Laravel 初〜中級のエンジニア:業務向け/社内向け/公開 API を正しく設計したい方
  • テックリード/アーキテクト:API を組織横断の基盤として標準化したい方
  • QA/CS/テクニカルライター:再現性の高い検証問い合わせ削減につながる仕様・文面を整えたい方
  • アクセシビリティ担当:ドキュメントやエラー表示をだれにとっても理解しやすくする運用を確立したい方

1. はじめに:API設計の原則とLaravelの強み

API は「長期間メンテされ、複数クライアントが使い続ける契約」です。変更は慎重に、曖昧さは排除し、再現性が要です。Laravel はルーティング・バリデーション・認証・シリアライゼーション・レート制限・CORS 等をフレームワークに内包しており、一貫したルールを確立しやすいのが強みです。本記事は、実務の型としてそのまま使えるコードと運用の勘所をまとめます。


2. リソース設計とURL/メソッド/ステータス

2.1 リソース命名とコレクション

  • /api/v1/posts(GET:一覧、POST:作成)
  • /api/v1/posts/{post}(GET:取得、PATCH:更新、DELETE:削除)
  • 子リソースは /posts/{post}/comments のように所属関係を表現
  • 名詞は複数形, スネークは避けハイフン区切り(可読性/URL共有に配慮)

2.2 ステータスコードの基本

  • 200 OK:取得/更新成功
  • 201 Created + Location:作成
  • 204 No Content:ボディなし成功(削除/一部更新)
  • 400/422:バリデーションエラー(422推奨)
  • 401/403:認証/権限問題
  • 404:未存在または非公開
  • 409:重複/競合(ユニーク制約)
  • 429:レート制限超過
  • 5xx:サーバー障害

2.3 ページネーション・並び替え・フィルタ

  • GET /posts?page=2&per_page=20&sort=-created_at&author_id=123
  • ホワイトリスト管理(想定外のカラムでの orderBy を禁止)
  • レスポンスにページ情報links, meta)を含める

3. Laravelでの基本配線:ルート・ポリシー・FormRequest

// routes/api.php
use App\Http\Controllers\Api\V1\PostController;

Route::prefix('v1')->middleware(['auth:sanctum'])->group(function () {
    Route::apiResource('posts', PostController::class);
    Route::get('posts/{post}/comments', [PostController::class,'comments']);
});
// app/Http/Requests/PostStoreRequest.php
class PostStoreRequest extends FormRequest {
    public function rules(): array {
        return [
            'title' => ['required','string','max:120'],
            'body'  => ['required','string'],
            'tags'  => ['array'],
            'tags.*'=> ['string','max:30'],
        ];
    }
    public function attributes(): array {
        return ['title'=>'タイトル','body'=>'本文'];
    }
}
// app/Policies/PostPolicy.php
class PostPolicy {
    public function update(User $user, Post $post): bool {
        return $post->user_id === $user->id || $user->tokenCan('posts:update');
    }
}
  • apiResource で REST の骨格を簡潔に
  • FormRequest入力検証項目名の自然言語化
  • Policy で行単位の認可を明示

4. 認証と認可:Sanctumの実装

4.1 Sanctum を選ぶ理由

  • SPA(Cookieベース)とモバイル/サーバークライアント(個別トークン)を両立
  • トークンに権限スコープ(Abilities)を付与可能
  • 設定が軽量で、Passport より導入が容易(OAuth2 が不要なら Sanctum 推奨)

4.2 導入と発行例

composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
// トークン発行(例:ユーザーが個人用トークンを作成)
$token = $user->createToken('cli', ['posts:read','posts:update'])->plainTextToken;
// Authorization: Bearer <token>
// 権限チェック(PolicyやControllerで)
if (! $request->user()->tokenCan('posts:update')) {
    abort(403);
}

4.3 SPA の場合

  • sanctum/csrf-cookie で CSRF 用クッキーを取得 → 以降はCookieベースで認証
  • CORS とクッキードメインを正しく設定(config/cors.php, SANCTUM_STATEFUL_DOMAINS

5. レスポンス整形:Eloquent API Resources

// app/Http/Resources/PostResource.php
class PostResource extends JsonResource {
    public function toArray($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'),
            'links'   => [
                'self' => route('posts.show', $this->id),
            ],
            'created_at' => $this->created_at->toIso8601String(),
        ];
    }
}
// コントローラ抜粋
public function index(Request $request) {
    $posts = Post::with(['author','tags'])->latest()->paginate(20);
    return PostResource::collection($posts)->additional([
        'meta' => ['api_version' => '1.0']
    ]);
}
  • API Resourcesでレスポンスを安定化し、内部スキーマ変更の影響を遮断
  • ISO 8601 で日付形式を統一
  • links/meta にページング・付加情報を集約

6. バージョニング:壊さないための約束

6.1 方式

  • URL 方式/api/v1/...(最も分かりやすくキャッシュ/モニタもしやすい)
  • ヘッダ方式:Accept: application/vnd.example.v2+json(変更波及を抑えやすいが運用負担大)
  • 重大変更時にメジャーを上げ、旧版は並走期間を設ける(EOLをドキュメントで明示)

6.2 ルーティング分割

app/Http/Controllers/Api/V1/...
app/Http/Controllers/Api/V2/...
  • 変化点が混在しないようにディレクトリで物理分割
  • 共有ドメインロジックはサービス層

7. OpenAPI(Swagger)で仕様を機械可読に

7.1 なぜ必要か

  • 仕様と実装の乖離を防ぎ、自動生成(型、クライアントSDK、APIテスト)が可能
  • 非同期連携が多い大規模組織で、仕様合意を可視化

7.2 最小サンプル(抜粋)

openapi: 3.0.3
info:
  title: Example API
  version: 1.0.0
paths:
  /api/v1/posts:
    get:
      summary: List posts
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostCollection'
components:
  schemas:
    Post:
      type: object
      properties:
        id: { type: string }
        title: { type: string }
        body: { type: string }
    PostCollection:
      type: object
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/Post' }
  • ツール(例:swagger-ui)で試せるドキュメントを生成
  • Laravel ではアノテーション/コマンドで YAML/JSON を出力するライブラリも活用可

8. エラー設計:開発者が原因を特定できる文面

8.1 422(バリデーション)の例

{
  "message": "入力内容を確認してください。",
  "errors": {
    "title": ["タイトルは必須です。"],
    "body": ["本文は100文字以上で入力してください。"]
  },
  "request_id": "9c47e2b9-..."
}
  • 項目ごとの配列で複数エラーを表現
  • request_id を付与して問い合わせ時に照合
  • メッセージは短く具体的、修正方針がわかる文に

8.2 429/401/403 の例

{ "message": "リクエストが多すぎます。30秒後に再試行してください。", "retry_after": 30 }
{ "message": "認証が必要です。" }
{ "message": "権限がありません。" }
  • レート制限時は再試行可能時刻を提示
  • 401 と 403 は使い分け(存在可否の秘匿が必要なら 404 を返す設計も)

9. キャッシュ&条件付きリクエスト:帯域を節約する

9.1 ETag/If-None-Match

public function show(Post $post, Request $request) {
    $payload = new PostResource($post);
    $etag = sha1(json_encode($payload));
    if ($request->header('If-None-Match') === $etag) {
        return response()->noContent(304);
    }
    return response($payload)->header('ETag', $etag)
        ->header('Cache-Control','public, max-age=60');
}
  • 内容ハッシュを ETag に、変更ないときは 304 を返す
  • 動的APIでも短いmax-ageでブラウザ/ゲートウェイキャッシュを活用

9.2 Last-Modified/If-Modified-Since

  • DB の updated_at を使い 304 を返す
  • Cache-Control/Vary を適切に設定(言語や認証ヘッダで応答が変わるときは要注意)

10. レート制限・Idempotency・CORS

10.1 レート制限

// app/Providers/RouteServiceProvider.php
RateLimiter::for('api', function ($request) {
    $key = optional($request->user())->id ?: $request->ip();
    return [Limit::perMinute(60)->by($key)];
});
  • 重要操作は専用キー・より厳格な制限で保護
  • 応答に Retry-After を含め、クライアント実装を助ける

10.2 冪等POST(Idempotency-Key)

  • クライアントが Idempotency-Key を送信
  • サーバはキーごとに結果を保存/再利用重複課金等を防ぐ(要実装)

10.3 CORS

  • config/cors.php最小権限
  • 資格情報(Cookie)とワイルドカード * の併用は不可
  • 本番は許可オリジンを列挙

11. 監査ログと可観測性

  • 誰が/いつ/何を/どこから行ったかを構造化ログに
  • request_id を全レイヤで引き回し、トレースしやすく
  • 4xx/5xx は要約メッセージ+原因を分けて記録(PII はマスク)

12. 国際化とドキュメントのアクセシビリティ

  • ドキュメントは日本語/英語を最小セットに
  • 各エンドポイントに「目的/パラメータ/成功例/失敗例/注意点」を見出し順で記載
  • 表だけでなく箇条書きを併記し、読み上げでも理解しやすく
  • コード色分けに頼りすぎず、コメント前後文で意味を補う
  • アンカーリンクやサイドナビでキーボード到達性を確保

13. 具体例:Post API の最小実装

13.1 ルート/コントローラ

// routes/api.php
Route::prefix('v1')->middleware(['auth:sanctum','throttle:api'])->group(function () {
    Route::apiResource('posts', \App\Http\Controllers\Api\V1\PostController::class);
});
// app/Http/Controllers/Api/V1/PostController.php
class PostController extends Controller
{
    public function index(Request $req) {
        $q = Post::with('author')->latest();
        if ($kw = $req->string('q')->toString()) {
            $q->where(fn($w) => $w->where('title','like',"%$kw%")->orWhere('body','like',"%$kw%"));
        }
        $posts = $q->paginate($req->integer('per_page') ?: 20);
        return PostResource::collection($posts);
    }

    public function store(PostStoreRequest $req) {
        $post = $req->user()->posts()->create($req->validated());
        return (new PostResource($post))
            ->response()
            ->setStatusCode(201)
            ->header('Location', route('posts.show', $post));
    }

    public function show(Post $post) {
        $etag = sha1($post->updated_at.$post->id);
        if (request()->header('If-None-Match') === $etag) {
            return response()->noContent(304);
        }
        return (new PostResource($post->load('author')))
            ->response()->header('ETag', $etag);
    }

    public function update(PostUpdateRequest $req, Post $post) {
        $this->authorize('update', $post);
        $post->update($req->validated());
        return new PostResource($post);
    }

    public function destroy(Post $post) {
        $this->authorize('delete', $post);
        $post->delete();
        return response()->noContent();
    }
}

13.2 サンプルリクエスト

# 一覧取得
curl -H "Authorization: Bearer $TOKEN" \
  "https://api.example.com/api/v1/posts?per_page=10&q=Laravel"

# 作成
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"title":"はじめてのAPI","body":"本文"}' \
  https://api.example.com/api/v1/posts

13.3 OpenAPI 断片(作成)

paths:
  /api/v1/posts:
    post:
      summary: Create post
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              required: [title, body]
              type: object
              properties:
                title: { type: string, maxLength: 120 }
                body:  { type: string }
      responses:
        '201':
          description: Created
        '422':
          description: Validation error

14. テスト戦略

14.1 Feature テスト

public function test_create_post_requires_auth(): void {
    $this->postJson('/api/v1/posts', ['title'=>'x','body'=>'y'])
         ->assertStatus(401);
}

public function test_create_post_validates_and_returns_201(): void {
    Sanctum::actingAs(User::factory()->create(), ['posts:read','posts:update']);
    $this->postJson('/api/v1/posts', ['title'=>'API','body'=>'本文'])
         ->assertCreated()
         ->assertHeader('Location')
         ->assertJsonPath('data.title','API');
}

14.2 契約テスト(OpenAPIベース)

  • 仕様ファイルを基にスキーマ検証
  • 破壊的変更(フィールド削除/型変更)を CI で検知

15. 運用チェックリスト

契約と互換性

  • [ ] 破壊的変更は v2 以降に限定し、移行期間と EOL を明示
  • [ ] OpenAPI を唯一の真実として管理

安全性

  • [ ] Sanctum のトークン権限を最小化
  • [ ] レート制限/監査ログ/アラートを運用に組込み

可用性

  • [ ] ETag/Last-Modified で帯域削減
  • [ ] ページネーションとホワイトリストソートで肥大化防止

アクセシビリティ(開発者体験)

  • [ ] すべてのエンドポイントに成功/失敗の例とメッセージを記載
  • [ ] 表に頼りすぎず、箇条書きと説明を併記
  • [ ] 日本語/英語の両方を提供(Accept-Language で切替可能なら尚良)

16. よくある落とし穴と回避策

  • レスポンス構造が画面都合で頻繁に変わる → API Resourcesで安定化し、表示用はクライアントで整形
  • orderBy($request->sort)直渡し → ホワイトリスト制御
  • バリデーションエラーが曖昧 → 422 で項目別配列と修正方法
  • * CORS + Cookie を併用 → ブラウザ仕様に反し失敗。オリジン列挙
  • バージョン混在で実装が迷子 → ディレクトリ分割と OpenAPI の単一管理
  • 作成系の重複課金/二重送信 → Idempotency-Key を導入

17. まとめ

  • Sanctum で軽量な認証、Policy で精密な認可
  • API Resources と OpenAPI で契約を安定化し、テストと運用に流す。
  • ETag/条件付きリクエスト・レート制限・CORS を整え、安全かつ軽いAPI を実現。
  • ドキュメントとエラー文は短く具体的に、成功/失敗の例を併記して誰でも理解できるようにする。
  • 破壊的変更はバージョンを分ける。旧版のEOLを明確にして混乱を防ぐ

参考リンク

投稿者 greeden

コメントを残す

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

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