Skip to main content

ルーティング設計と最初のエンドポイント

前章でテーブルとデータが揃いました。この章で、ようやく HTTP でアクセスできるようになります。

同時に、あとから変えにくいものをいくつか決めます。URL の形、バージョンの持ち方、どのステータスコードを返すか。これらは公開したクライアントが依存するので、後から変えると相手のコードが壊れます。実装より先に決めておく価値があります。

install:api で API 用の骨格を作る

第2章で見たとおり、新規プロジェクトのルートファイルは web.phpconsole.php の 2 つだけです。API 用のファイルは自分で用意します。

php artisan install:api

このコマンドは 3 つのことをします1

  1. routes/api.php を作る
  2. API トークン認証のパッケージ Laravel Sanctum をインストールする
  3. bootstrap/app.phpwithRoutingapi: を追加する

第2章で見た bootstrap/app.php が、次のように変わります。

bootstrap/app.php (install:api 実行後)
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 のルートはセッション状態にアクセスできません。認証はトークンで行う前提です。

最初のエンドポイント

商品一覧を返してみます。まずルートに直接書きます。

routes/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
app/Http/Controllers/ProductController.php
<?php

namespace App\Http\Controllers;

use App\Models\Product;

class ProductController extends Controller
{
public function index()
{
return Product::all();
}
}
routes/api.php
use App\Http\Controllers\ProductController;

Route::get('/products', [ProductController::class, 'index']);
このコードはまだ完成ではありません

Product::all()全件をメモリに読み込んで、モデルの全カラムをそのまま JSON にします。商品が 10 万件あれば 10 万件返しますし、あとで内部用のカラムを足せばそれも公開されます。第7章でレスポンスの形を設計し、第8章でページネーションを入れます。

apiResource でルートをまとめる

REST 的な API では、1 つのリソースに対する操作が決まった形に落ち着きます。Laravel はその定型を 1 行で書けるようにしています。

routes/api.php
Route::apiResource('products', ProductController::class);

この 1 行が 5 本のルートを定義します。

メソッドURIアクションルート名
GET/api/productsindexproducts.index
POST/api/productsstoreproducts.store
GET/api/products/{product}showproducts.show
PUT / PATCH/api/products/{product}updateproducts.update
DELETE/api/products/{product}destroyproducts.destroy

Route::resource() という似たメソッドもありますが、そちらは createedit を加えた 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() を付けると、親子関係の中から探すようになります。

✅ Good: 親の中から子を探す
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 にバージョンを含めます。

bootstrap/app.php
->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 に入れると、同じ操作に複数の書き方が生まれます。getProductsfetchProductsproductList のどれが正しいかは、規約がなければ決まりません。名詞に統一しておけば迷う余地がありません。

リソース名は複数形にします。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 は、23 と数字を変えるだけで他の注文を試せます。認可がなければ全件を順に取得できますし、認可があっても注文の総数が推測できます。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:apiroutes/api.php と Sanctum を用意し、bootstrap/app.phpapi: を追加する
  • 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章でレスポンスの形を設計するときには「あとで足せるが、消せない」という前提で決めます。


Footnotes

  1. 出典: Routing(Laravel 公式ドキュメント 13.x)。install:api の動作、apiPrefix によるプレフィックスの変更、ルートモデルバインディングのカスタムキー・スコープ・enum バインディング・missing について。 2 3

  2. 出典: Directory Structure(Laravel 公式ドキュメント 13.x)。routes/api.php が stateless でトークン認証を前提とし、セッション状態にアクセスできないことについて。

  3. 出典: Controllers(Laravel 公式ドキュメント 13.x)。リソースコントローラが扱う 7 アクション、apiResource が HTML テンプレートを返すルート (create / edit) を除外すること、shallow nesting について。 2