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

【現場完全ガイド】Laravelでつくる堅牢なAPIプラットフォーム――REST/JSON:API/GraphQL、OpenAPI、バージョニング、ETag/条件付きリクエスト、レート制限、idempotency、エラー設計、アクセシブルなドキュメント

この記事で学べること

  • REST・JSON:API・GraphQL の選び方と Laravel での実装指針
  • OpenAPI(OAS)による仕様駆動開発とリクエスト/レスポンスの検証
  • バージョニング(URI/ヘッダー/サブドメイン)、非互換変更の移行戦略
  • ETag/If-None-Match・If-Modified-Since によるキャッシュと帯域削減
  • レート制限・idempotency・署名付き Webhook など実務の信頼性向上テクニック
  • エラーの表現(RFC 7807 problem+json)・多言語メッセージ・トレースID
  • APIドキュメント/サンプル/SDK を誰にでも読みやすくするアクセシビリティ設計
  • テスト(Feature/Contract)・モニタリング・互換性チェックの運用

想定読者

  • Laravel 初〜中級のバックエンドエンジニア:APIを安全に公開し、破壊的変更を避けたい方
  • テックリード/PM:OpenAPI を中心にした仕様駆動とリリース管理を回したい方
  • QA/ドキュメント担当:API仕様・チュートリアル・サンプルをアクセシブルに届けたい方
  • 他サービス連携の開発者:Webhook/署名検証/idempotency の標準を整えたい方

アクセシビリティレベル:★★★★★

API自体の可用性だけでなく、ドキュメント・チュートリアル・サンプルコードを誰でも理解しやすい形で提供する方針を具体化します。色に依存しない表現、読み上げやコントラスト配慮、キーボード操作のみで完遂できるナビゲーション、サンプルのコピー容易性、図の代替テキストなどを含みます。


1. はじめに:APIは「契約」であり「公共物」

API は単なるエンドポイントではなく、長期にわたりクライアントと合意を守る「契約」です。破壊的変更や曖昧な解釈は、障害やサポート負荷として跳ね返ります。Laravel は認証・認可・ルーティング・バリデーション・キャッシュ・レート制限など API に必要な装置を一通り備えていますが、設計原則が無ければ活きません。この記事では、仕様駆動開発と互換性を軸に、堅牢で読みやすい API プラットフォームを段階的に組み立てます。


2. スタイルの選択:REST / JSON:API / GraphQL

2.1 ざっくり比較

  • REST(一般的なJSON)
    • 長所:学習コストが低く、HTTPの意味論に忠実。CDN/キャッシュが効きやすい。
    • 短所:表現の自由度が高く、実装ごとにバラつきが出やすい。
  • JSON:API(仕様準拠のREST)
    • 長所:ドキュメント/エラー/関連取得/ページングの約束が明確。クライアント実装が楽。
    • 短所:厳格ゆえに最初の学習コストがある。
  • GraphQL
    • 長所:過不足問題(over/under-fetch)を解消。複雑な画面に強い。
    • 短所:HTTP キャッシュが効きにくい。N+1やスキーマの保守が難しくなりがち。

小規模〜標準的な業務APIは REST(または JSON:API)で十分です。ダッシュボードの複雑な集約やモバイル画面の最適化が課題なら GraphQL を検討します。混在させると運用が複雑になるため、まずは REST をベースに共通原則(エラー・認証・ページング)を固めることをおすすめします。


3. 仕様駆動開発:OpenAPI を中心に

3.1 OAS を単一の真実にする

OpenAPI Specification(OAS)を単一の真実として扱い、サーバ・クライアント・テスト・ドキュメントをそこから生成/検証します。メリットは以下です。

  • 要件の曖昧さが減る
  • 破壊的変更の検出が容易
  • SDK/型生成でクライアントの安全性が上がる

3.2 ディレクトリ例

api/
├─ openapi.yaml            # 仕様(人手で管理+CIで検証)
├─ examples/               # リクエスト/レスポンスの実例
└─ schemas/                # JSON Schema(再利用)
app/
└─ Http/
   ├─ Controllers/Api/
   ├─ Middleware/
   └─ Requests/Api/
tests/
└─ Contract/               # OASとの契約テスト

3.3 Laravelでの型安全と検証

  • FormRequest に OAS と同じ制約を書く
  • さらに league/openapi-psr7-validator 等で実レスポンスが仕様に適合するかを契約テストで確認
  • spatie/laravel-data 等で DTO を活用し、レスポンス整形を型で守る

4. バージョニングと互換性の約束

4.1 方式の比較

  • URI プレフィックス:/api/v1/... が最も分かりやすく、キャッシュ/ルーティングも簡単
  • ヘッダー(Accept: application/vnd.example.v2+json):エレガントだが学習コストあり
  • サブドメイン:v2.api.example.com は API ゲートウェイと相性が良い

まずは /api/v1 を推奨。互換性を壊す変更は v2 で提供し、v1 は一定期間並行稼働します。

4.2 変更の分類と運用

  • 非破壊:フィールド追加、デフォルト拡張、ページサイズ上限の引き上げ
  • 準破壊:デフォルト値変更、並び順変更(通知が必要)
  • 破壊:フィールド削除、型変更、意味変更、エンドポイント削除

破壊的変更は避け、追加で表現できないか考えるのが原則です。どうしても必要なら、新バージョンを公開し、移行ガイドとリンター(互換チェック)を用意します。


5. 認証・認可・スコープ

5.1 認証方式

  • セッション+CSRF:同一オリジンのSPA向け
  • Sanctum のパーソナルアクセストークン:外部クライアント/API向け
  • OAuth 2.1 / OIDC(外部IdP):パートナー連携やSaaS統合向け

5.2 スコープ(abilities)

// 発行
$token = $user->createToken('cli', ['orders:read','orders:write'])->plainTextToken;
// 検証
abort_unless($request->user()->tokenCan('orders:write'), 403);

スコープを細かく切るほどセキュリティは上がりますが、運用が複雑になります。まずは「読み取り」「書き込み」の2分割から始め、必要に応じて拡張します。


6. ルーティング・命名・ページング・ソート

6.1 命名

  • 複数形のリソース:GET /ordersGET /orders/{id}POST /orders
  • 動詞はサブリソース:POST /orders/{id}/cancel のように意味を明示
  • 一貫性が最重要。短くて具体的に

6.2 ページング・ソート・フィルタ

GET /orders?page=2&per_page=50&sort=-created_at&status=shipped
  • per_page は上限を設ける(例:100)
  • ソートは列挙で許可。任意列は拒否
  • ページング情報はレスポンスに含める
{
  "data": [ ... ],
  "meta": { "total": 1234, "page": 2, "per_page": 50 },
  "links": { "next": "...", "prev": "..." }
}

7. 条件付きリクエストとHTTPキャッシュ

7.1 ETag / If-None-Match

各リソースの表現にハッシュ(ETag)を付与し、クライアントが If-None-Match を送ると、変更がなければ 304 Not Modified を返します。

public function show(Order $order) {
    $etag = sha1($order->updated_at.$order->id);
    if (request()->header('If-None-Match') === $etag) {
        return response()->noContent(304);
    }
    return response()->json($order)->header('ETag', $etag);
}

7.2 Last-Modified / If-Modified-Since

時刻ベースの条件付きも役立ちます。帯域削減と「無駄な再取得」の抑止は、コストとレイテンシの両方を下げます。


8. レート制限・idempotency・リトライ

8.1 レート制限

// routes/api.php
Route::middleware('throttle:60,1')->group(function(){
  Route::get('/orders', [OrderController::class,'index']);
});
  • APIキーやユーザーIDを「by」に使い分けるとフェアに
  • 429 Too Many RequestsRetry-After を返す

8.2 idempotency(重複防止)

決済や作成系は冪等性キーで二重実行を防ぎます。

// ミドルウェアの概略
$key = request()->header('Idempotency-Key');
abort_unless($key, 400);
$lock = Cache::lock("idem:$key", 60);
abort_unless($lock->get(), 409); // 進行中
try {
  // 同一キーの既存レスポンスがあれば再送
  if ($cached = Cache::get("idem:resp:$key")) return response()->json($cached['body'], $cached['status']);
  $resp = $next($request);
  Cache::put("idem:resp:$key", ['status'=>$resp->status(), 'body'=>json_decode($resp->getContent(),true)], 3600);
  return $resp;
} finally { optional($lock)->release(); }

8.3 リトライ方針

HTTPクライアントからのリトライは指数バックオフ(例:100ms, 200ms, 400ms, …)。サーバ側は安全な再実行を可能にします。


9. エラー設計:RFC 7807(problem+json)で統一

9.1 フォーマット

{
  "type": "https://docs.example.com/problems/validation",
  "title": "Validation Failed",
  "status": 422,
  "detail": "email は必須です。",
  "instance": "/api/v1/users",
  "errors": {
    "email": ["この項目は必須です。"]
  },
  "trace_id": "req-8a2c..."
}
  • type はドキュメントのURL(固定ページ)にして再現手順や対処を明記
  • trace_id を必ず付け、問い合わせやログ探索を短時間で

9.2 Laravel での実装

例外ハンドラで Throwable を捕まえ、ステータスコード別に problem+json で返す。FormRequest からのバリデーションエラーも統一します。

// app/Exceptions/Handler.php(一部)
public function render($request, Throwable $e)
{
    if ($request->expectsJson()) {
        $traceId = (string) Str::uuid();
        Log::error('api.error', ['trace_id'=>$traceId, 'ex'=>$e]);

        $status = $this->statusOf($e); // 例外から適切なHTTPコードへ
        $payload = [
          'type' => $this->problemType($e),
          'title' => Response::$statusTexts[$status] ?? 'Error',
          'status' => $status,
          'detail' => $this->detailOf($e),
          'instance' => $request->path(),
          'trace_id' => $traceId,
        ];

        if ($e instanceof ValidationException) {
          $payload['type'] = 'https://docs.example.com/problems/validation';
          $payload['errors'] = $e->errors();
        }

        return response()->json($payload, $status)->header('Content-Type','application/problem+json');
    }
    return parent::render($request, $e);
}

9.3 多言語メッセージ

detailAccept-Language を尊重してローカライズ。ただし、機械可読な typestatus は変えません。英語版の detail も別途入手できるとサポートが楽になります。


10. JSON セキュア化とサイズ管理

  • application/json を使用し、JSONP は禁止
  • 出力は最小限のフィールド。可観測性のための内部情報(スタックトレースやPII)は返さない
  • 一覧はページング必須。per_page の上限で帯域コントロール
  • 大きな配列はストリーミング非同期エクスポートに逃がす

11. Webhook:署名検証とリプレイ対策

11.1 送信側(こちらが打つ)

  • ヘッダーに署名と時刻を入れる
  • 冪等性キー(イベントID)を付与
$payload = json_encode($event, JSON_UNESCAPED_UNICODE);
$ts = time();
$sig = hash_hmac('sha256', $ts.'.'.$payload, config('services.webhook.secret'));
Http::withHeaders([
  'X-Webhook-Timestamp' => $ts,
  'X-Webhook-Signature' => $sig,
  'Idempotency-Key' => $event['id'],
])->post($url, $event);

11.2 受信側(外部から受ける)

  • まず署名検証、時刻の許容誤差(例:±5分)
  • 受信後はジョブに投げて非同期処理、同一イベントIDは無視
$payload = $request->getContent();
$ts = $request->header('X-Webhook-Timestamp');
$sig = $request->header('X-Webhook-Signature');

abort_if(abs(time() - (int)$ts) > 300, 401);
$calc = hash_hmac('sha256', $ts.'.'.$payload, config('services.webhook.secret'));
abort_unless(hash_equals($calc, $sig), 401);

$event = json_decode($payload, true);
if (Cache::add('evt:'.$event['id'], true, 3600)) {
  dispatch(new HandleWebhook($event));
}

12. ドキュメントのアクセシビリティ:読みやすく、探しやすく、試しやすく

12.1 情報設計

  • 左カラムに目次(キーボード操作で展開可能)
  • 1ページ1テーマ。最初に「できること」「対象読者」「最短手順」を提示
  • 背景色やバッジ色に頼らず、テキストアイコンで状態を表現
  • 図・シーケンスには代替テキスト長めの説明

12.2 サンプルの作り方

  • cURL・JavaScript(fetch/axios)・PHP(HTTPクライアント)・Python(requests)の最低4言語
  • コピーボタンはキーボード操作で押せる
  • 長いコードは折りたたみ可能。見出しと説明を必ず付ける
  • 失敗例(401/403/422/429/500)も掲載し、どう直せば良いかを短文で

12.3 エラーカタログ

type ごとに固定URLで公開。原因・対処・再現条件・リトライ可否・サポート窓口を明記。色は補助に留め、テキストが主役。


13. 実装サンプル:注文API(抜粋)

13.1 ルート

Route::prefix('api/v1')->middleware(['auth:sanctum', 'throttle:120,1'])->group(function () {
  Route::get('/orders', [OrderController::class,'index']);
  Route::post('/orders', [OrderController::class,'store'])->middleware('idem.key'); // 冪等性
  Route::get('/orders/{order}', [OrderController::class,'show']);
  Route::post('/orders/{order}/cancel', [OrderController::class,'cancel']);
});

13.2 検証

class StoreOrderRequest extends FormRequest {
  public function rules(): array {
    return [
      'items' => ['required','array','min:1'],
      'items.*.sku' => ['required','string'],
      'items.*.qty' => ['required','integer','min:1','max:100'],
      'note' => ['nullable','string','max:500']
    ];
  }
}

13.3 コントローラ(ETag対応の show)

public function show(Order $order)
{
    $this->authorize('view', $order);
    $etag = sha1($order->updated_at.$order->id);
    if (request()->header('If-None-Match') === $etag) {
        return response()->noContent(304)->header('ETag',$etag);
    }
    return response()->json([
        'data' => [
           'id'=>$order->id,
           'status'=>$order->status,
           'total'=>$order->total,
           'items'=>$order->items()->get(['sku','name','qty','price']),
        ],
        'meta' => [ 'currency'=>'JPY' ]
    ], 200)->header('ETag',$etag);
}

13.4 エラー(problem+json の422)

throw ValidationException::withMessages([
  'items.0.sku' => ['このSKUは存在しません。']
]);

14. 契約テストと互換性チェック

14.1 コントラクトテスト

  • OAS を読み込み、各エンドポイントについて実応答が仕様適合かをチェック
  • スキーマ差分(backward incompatible)を CI で検出(例:型変更/必須化)

14.2 リグレッション抑止

  • 代表的なクライアント(社内SDK)のE2Eスモークを毎デプロイで実施
  • trace_id をキーに、障害発生時の調査パスを短縮

15. モニタリングとSLO

  • p50/p95/p99 のレイテンシ、エラー率(5xx/4xx)、レート制限到達率、キャッシュヒット率
  • バックエンドのキュー遅延、データベースのスロークエリ
  • 重要エンドポイントの SLO(例:p95<300ms、5xx<0.1%)を明文化し、逸脱時にアラート

16. セキュリティ強化の要点

  • HSTS・CSP・X-Content-Type-Options: nosniffReferrer-Policy
  • 入力検証(FormRequest)と、SQL/テンプレート/コマンドインジェクション対策
  • ファイル受け取り時の MIME 検証・サイズ制限・ウイルススキャン
  • 監査ログ:trace_iduser_idippathstatuslatency を構造化して保存
  • 機微情報のマスキング(トークン/カード/個人識別子)

17. アクセシブルなサンプルアプリとポータル

  • ポータルのナビゲーションはキーボードで操作でき、フォーカスリングが見える
  • コードブロックに言語ラベルコピーボタン、色弱でも区別可能な配色
  • 表やパラメータ定義には見出し説明を用意。アイコンだけで説明しない
  • サンプルリクエストは curl と各言語で同じ内容を示し、レスポンスも整形して両方提供
  • ダークモード対応と十分なコントラスト比(WCAG AA 以上)

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

  • 任意ソート/フィルタをそのままSQLへ → 列挙と妥当性検証でガード
  • 形だけのバージョン番号 → 破壊的変更を避ける運用と移行ガイドをセットで
  • 大きな一覧の全件返却 → ページング必須、ETag/条件付きで再取得コストを削減
  • 不統一なエラーフォーマット → RFC 7807 で統一し、エラーカタログへ誘導
  • 冪等性なしの作成系 → Idempotency-Key とサーバ側のロック/キャッシュ
  • Webhook のなりすまし → 署名検証とリプレイ対策、イベントIDの重複無視
  • ドキュメントの色依存 → テキスト主導、代替テキスト、キーボードで完遂できるUI
  • 追跡不能な障害 → trace_id を全レスポンスに付与し、ログと紐付け

19. チェックリスト(配布用)

設計

  • [ ] スタイル(REST/JSON:API/GraphQL)を選定し、逸脱ルールを定義
  • [ ] OpenAPI を単一の真実に。契約テストをCIに組込み
  • [ ] バージョニングは /api/v{n}、破壊的変更の方針を明文化

機能

  • [ ] 認証(Sanctum/OAuth)とスコープで最小権限
  • [ ] ページング/ソート/フィルタは列挙と上限値
  • [ ] ETag/条件付きリクエストで帯域削減
  • [ ] レート制限・idempotency・リトライ方針

エラー

  • [ ] RFC 7807 で統一、trace_id を必ず返す
  • [ ] 多言語 detail とエラーカタログのURL

セキュリティ/運用

  • [ ] 署名付き Webhook とリプレイ対策
  • [ ] 監査ログの構造化とPIIマスク
  • [ ] SLO とメトリクスのダッシュボード

ドキュメント/アクセシビリティ

  • [ ] 目次・キーボード操作・コントラスト・代替テキスト
  • [ ] cURL+複数言語のサンプルとコピー機能
  • [ ] 失敗例と対処の明示

20. まとめ

API は、長く使われる公共物です。OpenAPI を中心に、変更の互換性を守り、エラー表現を統一し、キャッシュやレート制限で健全性を保ちましょう。作成系は idempotency で二重実行に強くし、Webhook は署名検証とリプレイ対策で堅牢に。ドキュメントは誰にでも読みやすく、試しやすく。色や画像だけに依存せず、テキストと構造で伝えます。今日の設計とサンプルを土台に、チームのAPI標準を整備してください。落ち着いて育てれば、利用者にも開発者にもやさしいプラットフォームになります。


参考リンク

投稿者 greeden

コメントを残す

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

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