【実務完全ガイド】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を明確にして混乱を防ぐ。
参考リンク
- Laravel(公式)
- HTTP/REST と標準仕様
- ドキュメント規格
- 設計ガイド