ルーティング設計と最初のエンドポイント
前章でテーブルとデータが揃いました。この章で、ようやく HTTP でアクセスできるようになります。
同時に、あとから変えにくいものをいくつか決めます。URL の形、バージョンの持ち方、どのステータスコードを返すか。これらは公開したクライアントが依存するので、後から変えると相手のコードが壊れます。実装より先に決めておく価値があります。
install:api で API 用の骨格を作る
第2章で見たとおり、新規プロジェクトのルートファイルは web.php と console.php の 2 つだけです。API 用のファイルは自分で用意します。
php artisan install:api
このコマンドは 3 つのことをします1。
routes/api.phpを作る- API トークン認証のパッケージ Laravel Sanctum をインストールする
bootstrap/app.phpのwithRoutingにapi:を追加する
第2章で見た bootstrap/app.php が、次のように変わります。
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php', // 追加された
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
// ...
Sanctum も一緒に入りますが、使うのは第11章です。いまは routes/api.php が用意されたことだけ確認できれば十分です。
routes/api.php に書いたルートには、次の 2 つが自動的に適用されます2。
/apiの URL プレフィックス — ファイルに/productsと書けば、実際の URL は/api/productsになるapiミドルウェアグループ — セッションを持たない stateless な扱いになる
web.php との違いはこの stateless という点です。web.php のルートはセッション・CSRF 保護・cookie 暗号化が働きますが、api.php のルートはセッション状態にアクセスできません。認証はトークンで行う前提です。
最初のエンドポイント
商品一覧を返してみます。まずルートに直接書きます。
use App\Models\Product;
use Illuminate\Support\Facades\Route;
Route::get('/products', function () {
return Product::all();
});
前章でシーダーを流していれば、http://localhost:8000/api/products で 20 件の商品が JSON で返ります。
php artisan serve
curl http://localhost:8000/api/products
第2章で追った経路を、いま書いたコードが通っています。リクエストは public/index.php に入り、サービスコンテナが作られ、HTTP カーネルが api ミドルウェアグループを通し、ルーターがこのクロージャを呼びました。
ただし、この書き方は最初の 1 本だけです。処理が増えるとルートファイルが読めなくなるので、コントローラに移します。
php artisan make:controller ProductController
<?php
namespace App\Http\Controllers;
use App\Models\Product;
class ProductController extends Controller
{
public function index()
{
return Product::all();
}
}
use App\Http\Controllers\ProductController;
Route::get('/products', [ProductController::class, 'index']);
Product::all() は全件をメモリに読み込んで、モデルの全カラムをそのまま JSON にします。商品が 10 万件あれば 10 万件返しますし、あとで内部用のカラムを足せばそれも公開されます。第7章でレスポンスの形を設計し、第8章でページネーションを入れます。
apiResource でルートをまとめる
REST 的な API では、1 つのリソースに対する操作が決まった形に落ち着きます。Laravel はその定型を 1 行で書けるようにしています。
Route::apiResource('products', ProductController::class);
この 1 行が 5 本のルートを定義します。
| メソッド | URI | アクション | ルート名 |
|---|---|---|---|
| GET | /api/products | index | products.index |
| POST | /api/products | store | products.store |
| GET | /api/products/{product} | show | products.show |
| PUT / PATCH | /api/products/{product} | update | products.update |
| DELETE | /api/products/{product} | destroy | products.destroy |
Route::resource() という似たメソッドもありますが、そちらは create と edit を加えた 7 本を定義します。この 2 つは入力フォームの HTML を返すためのもので、API には要りません。apiResource() はこの 2 本を除外します3。
対応するコントローラは、雛形から作れます。
php artisan make:controller ProductController --api --model=Product
--api で 5 つのメソッドを持つコントローラが、--model=Product で各メソッドの引数に型宣言が入った状態で生成されます。
apiResource() は store / update / destroy も定義します。認証がまだないので、誰でも商品を作成・更新・削除できる状態です。第1章で問題提起したとおりで、第11章の認証と第12章の認可で塞ぎます。
それまでは、公開したいものだけを only() で絞っておきます。
Route::apiResource('products', ProductController::class)->only(['index', 'show']);
この連載では、第11章で認証を入れるまでこの形で進めます。第1章で示した最終形の一覧に商品の書き込み系がほとんど無いのは、管理者向けの機能を対象外にしているためです。例外は第19章で足す画像のアップロードで、専用のトークンで守ります。
定義されたルートは確認できます。
php artisan route:list
このコマンドは、実際に登録されているルートとミドルウェアの一覧を出します。「ルートを書いたのに 404 になる」ときは、まずこれを見ます。プレフィックスの付き方やメソッドの取り違えがその場で分かります。
ルートモデルバインディング
show アクションを素直に書くと、こうなります。
public function show(int $id)
{
$product = Product::findOrFail($id);
return $product;
}
Laravel は、この「ID を受け取ってモデルを取ってくる」処理を肩代わりできます。引数に型を書くだけです。
public function show(Product $product)
{
return $product;
}
ルートのパラメータ名 {product} と引数の変数名 $product が一致していると、Laravel が Product を主キーで探して渡します。見つからなければ自動的に 404 を返します。
主キー以外で探す
商品を id ではなく slug で引きたいことがあります。ルート側でカラムを指定します。
Route::get('/products/{product:slug}', [ProductController::class, 'show']);
これで /api/products/organic-coffee-beans のような URL が使えます。
ただし、この連載の products テーブルに slug カラムは作っていません。手元で試すなら、マイグレーションで $table->string('slug')->unique(); を足してください。以降の章では id で引く形のまま進めます。
ネストしたリソースは範囲を絞る
注文に紐づく明細を返す、といったネストした URL を作ると、注意すべき点が出てきます。
Route::get('/orders/{order}/items/{item}', function (Order $order, OrderItem $item) {
return $item;
});
この書き方だと、{item} は {order} と無関係に主キーだけで探されます。つまり /api/orders/1/items/999 にアクセスしたとき、明細 999 が別の注文のものであっても返ってしまいます。他人の注文明細が見えるということです。
scopeBindings() を付けると、親子関係の中から探すようになります。
Route::scopeBindings()->group(function () {
Route::get('/orders/{order}/items/{item}', function (Order $order, OrderItem $item) {
return $item;
});
});
なお、子のパラメータにカスタムキーを指定した場合 ({item:code} のような形) は、Laravel が自動的に親でスコープします1。カスタムキーを使っていないときだけ scopeBindings() が要る、という区別です。
この連載では、ネストした URL は使わずに /api/orders/{order} で明細ごと返す形にします。ネストが深い URL は組み合わせが増え、認可の抜けが生まれやすいためです。
enum もバインドできる
第3章で作った OrderStatus のような backed enum も、そのまま受け取れます。
use App\Enums\OrderStatus;
Route::get('/orders/status/{status}', function (OrderStatus $status) {
return $status->value;
});
enum に存在しない値が来た場合は 404 になります。バリデーションを書かなくても、URL の時点で弾けます。
見つからないときの挙動を変える
既定では 404 ですが、変えられます。
Route::get('/products/{product:slug}', [ProductController::class, 'show'])
->name('products.show')
->missing(fn () => response()->json(['message' => '商品が見つかりません'], 404));
API ではエラーレスポンスの形を統一したいので、この指定を各ルートに書くよりも、第9章で扱う withExceptions にまとめる方法をとります。
URL の設計とバージョニング
バージョンを URL に持つ
公開した API は、あとから形を変えられません。レスポンスの構造を変えれば、それを読んでいるクライアントが壊れます。
対策として、URL にバージョンを含めます。
->withRouting(
api: __DIR__.'/../routes/api.php',
apiPrefix: 'api/v1',
// ...
)
apiPrefix は /api の既定値を変えるオプションです1。これで全ルートが /api/v1/products の形になります。
バージョンは増やす方向にだけ動かします。v2 を追加しても v1 はしばらく残し、利用者に移行の期間を与えてから止めます。予告なく消すと、こちらの都合で相手のシステムが止まります。
URI にはリソースの名前を置く
REST 的な設計では、URL は「もの」を表し、それに対する操作は HTTP メソッドで表します。
✅ GET /api/v1/products 商品の一覧を取得する
✅ POST /api/v1/orders 注文を作成する
✅ DELETE /api/v1/orders/{order} 注文を削除する
❌ GET /api/v1/getProducts
❌ POST /api/v1/createOrder
❌ POST /api/v1/deleteOrder
動詞を URL に入れると、同じ操作に複数の書き方が生まれます。getProducts、fetchProducts、productList のどれが正しいかは、規約がなければ決まりません。名詞に統一しておけば迷う余地がありません。
リソース名は複数形にします。apiResource('products', ...) が生成する URL もこの形です。
HTTP メソッドとステータスコード
返すコードは、クライアントが機械的に判断する材料です。「とりあえず 200 を返して、本文に成功したかどうかを書く」設計にすると、クライアントは毎回本文を解析することになります。
この連載で使うものを挙げます。
| コード | 意味 | 使う場面 | 扱う章 |
|---|---|---|---|
| 200 OK | 成功 | 取得・更新が成功した | 本章 |
| 201 Created | 作成された | 注文が作られた | 第5章 |
| 204 No Content | 成功、本文なし | 削除が成功した | 本章 |
| 401 Unauthorized | 認証されていない | トークンがない・無効 | 第11章 |
| 403 Forbidden | 権限がない | 他人の注文をキャンセルしようとした・トークンの権限が足りない | 第12章・第19章 |
| 404 Not Found | 見つからない | ID に対応するものがない | 本章 |
| 409 Conflict | 状態が矛盾する | 在庫不足・発送済み注文のキャンセル | 第12章・第14章 |
| 422 Unprocessable Entity | 入力が不正 | バリデーションエラー | 第5章 |
| 429 Too Many Requests | 回数の上限 | レート制限に達した | 第13章 |
| 500 Internal Server Error | サーバー側の問題 | 想定外の例外 | 第9章 |
Laravel は、いくつかを自動で返します。ルートモデルバインディングで見つからなければ 404、フォームリクエストのバリデーションが失敗すれば 422 です。
コントローラから明示的に指定する場合はこう書きます。
return response()->json($product, 201);
return response()->noContent(); // 204
本番で効く注意点
URL に連番の ID を出すか決める
/api/v1/orders/1 のような URL は、2、3 と数字を変えるだけで他の注文を試せます。認可がなければ全件を順に取得できますし、認可があっても注文の総数が推測できます。1 月に注文 1000 番、2 月に 1500 番なら、月間 500 件と分かります。
対策は 2 つあります。1 つは推測できない識別子を使うことです。Laravel は ULID や UUID を主キーにする仕組みを持っています。もう 1 つは、認可を確実にかけて、他人のリソースには 404 を返すことです (403 だと「存在はする」ことが伝わります)。
この連載では、後者を第12章で扱います。前者は主キーの設計に踏み込むのでデータベース設計ガイドの領域です。
ネストを深くしない
/api/v1/users/1/orders/2/items/3/reviews/4 のような URL は、階層が増えるほど「どの組み合わせが有効か」の検証が増えます。3 段目で親を確認し忘れれば、そこが穴になります。
実務では 1 段までに留め、それ以上は子の ID だけで引ける形にします。Laravel はこれを shallow nesting としてサポートしています3。子の ID が一意なら、親の ID を URL に含める必要はありません。
ルートキャッシュは本番でだけ使う
php artisan route:cache はルート定義を 1 ファイルにまとめて起動を速くします。本番のデプロイ手順に入れる価値がありますが、キャッシュ後にルートを変更しても反映されません。ローカルでは使わず、デプロイ時にだけ実行します。第21章の手順に含めます。
まとめ
php artisan install:apiがroutes/api.phpと Sanctum を用意し、bootstrap/app.phpにapi:を追加するapiResource()は 5 本のルートを定義する。resource()の 7 本から HTML フォーム用の 2 本を除いた形- 引数に型を書くとルートモデルバインディングが働き、見つからなければ 404 になる
- ネストした URL でカスタムキーを使わない場合は
scopeBindings()が要る。付けないと他人の子リソースが引ける - URL は名詞、操作は HTTP メソッド。バージョンは増やす方向にだけ動かす
- ステータスコードはクライアントが機械的に判断する材料。200 で統一しない
次に読む
次章 入力の検証 — Form Request で境界を固める から、データを書き込む側に入ります。注文を作る API を素朴に書いてみて、コントローラに直接書いたバリデーションを Form Request へ移します。形式のチェックとビジネスルールのチェックの境界線も、そこで引きます。
練習問題
次のルート定義には、他人のデータが見えてしまう問題があります。原因と対策を説明してください
Route::get('/orders/{order}/items/{item}', function (Order $order, OrderItem $item) {
return $item;
});
解答例
{item} のルートモデルバインディングは、{order} と無関係に order_items テーブルを主キーで検索します。
/api/v1/orders/1/items/500 にアクセスしたとき、明細 500 が注文 1 のものかどうかは確認されません。別の顧客の注文 999 に属する明細でも、そのまま返ります。URL の 1 の部分は Order を解決するために使われただけで、{item} の検索条件には入っていません。
攻撃する側から見ると、自分の注文番号を 1 つ知っていれば、items の ID を総当たりするだけで他人の明細を集められます。
対策は scopeBindings() です。
Route::scopeBindings()->group(function () {
Route::get('/orders/{order}/items/{item}', function (Order $order, OrderItem $item) {
return $item;
});
});
これで $order->items() の中から {item} を探すようになり、他の注文の明細は 404 になります。
なお、子のパラメータにカスタムキーを指定した場合 ({item:code}) は自動的にスコープされます。この非対称性が事故のもとなので、ネストしたルートを書くときは scopeBindings() を明示するほうが安全です。
API を公開したあと、商品一覧のレスポンスに含まれる price を「円」から「円 + 税込表示」に変えたくなりました。どう進めますか
解答例
既存の price の意味を変えるのは避けます。 すでに /api/v1/products を叩いているクライアントは price を税抜として扱っており、意味だけ変えると計算結果が黙って変わります。エラーにならないぶん、発見が遅れます。
現実的な選択肢は 2 つです。
フィールドを追加する: price はそのまま残し、price_including_tax を足します。追加は既存クライアントを壊しません。読んでいないフィールドが増えても無視されるだけです。この方法が使えるなら最も安全です。
バージョンを上げる: レスポンスの構造そのものを整理したい場合は /api/v2/products を作ります。v1 は残したまま、利用者に移行期間を告知します。両方を維持するコストがかかるので、変更が大きいときの手段です。
判断の軸は「既存クライアントのコードが壊れるか」です。フィールドの追加は壊れず、削除と意味の変更は壊れます。この区別があるので、第7章でレスポンスの形を設計するときには「あとで足せるが、消せない」という前提で決めます。