Laravelミドルウェアの徹底解説
ミドルウェアはLaravelの「セキュリティチェックポイントパイプライン」です。各リクエストは荷物のように層ごとのチェックを通過し, 不合格のものは即座に傍受され, 合格したものは次のステージに進みます。
1. 学ぶこと
- ミドルウェアの実行フローとパイプラインパターン
- 3つの登録方法:グローバル, ルート, ミドルウェアグループ
- カスタムミドルウェア:レート制限 / CORS / テナント解決
- ミドルウェアのパラメータ渡し:throttle:60,1 / role:admin
- ミドルウェアのソートと優先度制御
2. アーキテクトの本当の話
(1) 痛点:すべてのセキュリティチェックがコントローラに集中している
Bobは各コントローラメソッドの先頭に10行のバリデーションコードを書いていました。認証チェック, テナント分離, レート制限, CORS, ログ。50メソッド x 10行 = 500行の重複コード。Aliceが新しいセキュリティポリシーを追加する際, 50箇所を変更する必要があり, 3箇所の見落としがセキュリティ脆弱性につながりました。Charlieは「ミドルウェアって聞いたことない?」と言いました。
(2) ミドルウェアの解決策
ミドルウェアは共通のバリデーションロジックを個別のクラスに抽出し, 各リクエストが自動的に複数層のミドルウェアを通過します。認証, レート制限, CORS, テナント分離を経て, コントローラはビジネスロジックに専念します。
// 変更前 — 各メソッドに10行のチェック
public function index() {
if (!auth()->check()) abort(401);
if (!tenant()->isActive()) abort(403);
if (RateLimiter::tooManyAttempts(...)) abort(429);
// ... ようやくビジネスロジック
}
// 変更後 — ミドルウェアがすべてのチェックを処理
Route::middleware(['auth', 'tenant.resolve', 'throttle:60,1'])
->get('/shops', [ShopController::class, 'index']);
// コントローラにはビジネスロジックのみ
(3) 成果
Bobがミドルウェアを実装した後, コントローラのコードが60%削減されました。Aliceの新しいセキュリティポリシーは1つのミドルウェアを変更するだけで済み, 見落としはゼロになりました。
3. ミドルウェアパイプラインの実行フロー
(1) リクエストがミドルウェアを通過する全体プロセス
flowchart LR
A[リクエスト] --> B[グローバルMW 1]
B --> C[グローバルMW 2]
C --> D[ルートMW 1]
D --> E[ルートMW 2]
E --> F[コントローラ]
F --> G[レスポンス]
G --> H[後MW 2]
H --> I[後MW 1]
I --> J[クライアント]
(2) オニオンモデル
リクエスト →
ミドルウェア A (before) →
ミドルウェア B (before) →
コントローラ → レスポンス
ミドルウェア B (after) →
ミドルウェア A (after) →
レスポンス
各ミドルウェアは以下のことが可能です:
- Before:リクエストがコントローラに到達する前にロジックを実行
- After:コントローラがレスポンスを返した後にロジックを実行
- ショートサーキット:次の層に渡さずに即座にレスポンスを返す
(1) ▶ サンプル:ミドルウェアのオニオンモデルを理解する
// app/Http/Middleware/LogRequests.php
class LogRequests
{
public function handle(Request $request, Closure $next): Response
{
// BEFORE — 受信リクエストをログ
Log::info('Request:', [
'method' => $request->method(),
'url' => $request->fullUrl(),
'ip' => $request->ip(),
]);
// 次のミドルウェアに渡す
$response = $next($request);
// AFTER — レスポンスステータスをログ
Log::info('Response:', [
'status' => $response->getStatusCode(),
'duration' => defined('LARAVEL_START') ? round((microtime(true) - LARAVEL_START) * 1000) : null,
]);
return $response;
}
}
出力:
// 実行成功
4. 3つの登録方法
(1) グローバルミドルウェア
すべてのリクエストで実行されます。ルートでの指定は不要です。
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->append([
\App\Http\Middleware\LogRequests::class,
\App\Http\Middleware\SetLocale::class,
]);
$middleware->remove([
\Illuminate\Foundation\Http\Middleware\TrimStrings::class,
]);
})
(2) ルートミドルウェア
ルート定義で指定するミドルウェアです。
// エイリアスの登録
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'tenant' => \App\Http\Middleware\TenantResolve::class,
'role' => \App\Http\Middleware\CheckRole::class,
'ability' => \App\Http\Middleware\CheckAbility::class,
]);
})
// ルートでの使用
Route::middleware(['auth', 'tenant', 'role:admin'])
->get('/admin/users', [AdminController::class, 'users']);
(3) ミドルウェアグループ
ミドルウェアのセットをパッケージ化し, グループとして適用します。
| グループ名 | デフォルトの内容 | 適用先 |
|---|---|---|
web |
StartSession, EncryptCookies, VerifyCsrfToken | Webルーティング |
api |
Throttle:api, SubstitueBindings | APIルーティング |
// ミドルウェアグループのカスタマイズ
->withMiddleware(function (Middleware $middleware) {
$middleware->appendToGroup('api', [
\App\Http\Middleware\TenantResolve::class,
]);
$middleware->prependToGroup('web', [
\App\Http\Middleware\SetLocale::class,
]);
})
| 登録方法 | タイミング | 適用場面 |
|---|---|---|
| グローバル | すべてのリクエスト | ログ, CORS |
| ルートエイリアス | 指定ルート | 認証, テナント |
| ミドルウェアグループ | グループ内ルート | Web/APIグループ |
(1) ▶ サンプル:ShopMetricsミドルウェア登録
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
// グローバルミドルウェア
$middleware->append([
\App\Http\Middleware\SetLocale::class,
]);
// エイリアス
$middleware->alias([
'tenant' => \App\Http\Middleware\TenantResolve::class,
'role' => \App\Http\Middleware\CheckRole::class,
'ability' => \App\Http\Middleware\CheckAbility::class,
]);
// APIグループに追加
$middleware->appendToGroup('api', [
\App\Http\Middleware\EnsureJsonAccept::class,
]);
})
出力:
// 実行成功
5. カスタムミドルウェア
(1) ミドルウェアの作成
php artisan make:middleware TenantResolve
php artisan make:middleware CheckRole
php artisan make:middleware EnsureJsonAccept
(2) TenantResolveミドルウェア
// app/Http/Middleware/TenantResolve.php
class TenantResolve
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (!$user || !$user->tenant_id) {
abort(403, 'No tenant associated with this account.');
}
$tenant = $user->tenant;
if ($tenant->status !== 'active') {
abort(403, 'Tenant account is ' . $tenant->status . '.');
}
// グローバルスコープ用にテナントをコンテナに保存
app()->instance(Tenant::class, $tenant);
// リクエストにテナントコンテキストを設定
$request->attributes->set('tenant', $tenant);
return $next($request);
}
}
(3) CORSミドルウェア
// app/Http/Middleware/HandleCors.php
class HandleCors
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('Access-Control-Allow-Origin', config('cors.allowed_origins'));
$response->headers->set('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
$response->headers->set('Access-Control-Allow-Headers', 'Content-Type,Authorization,X-Tenant-Id');
$response->headers->set('Access-Control-Max-Age', '86400');
if ($request->isMethod('OPTIONS')) {
$response->setStatusCode(204);
}
return $response;
}
}
(1) ▶ サンプル:ShopMetricsレート制限ミドルウェア
// app/Http/Middleware/PerTenantRateLimit.php
class PerTenantRateLimit
{
public function handle(Request $request, Closure $next, int $maxAttempts = 60, int $decayMinutes = 1): Response
{
$tenant = app()->make(Tenant::class);
$key = 'tenant:' . $tenant->id . ':' . $request->ip();
if (RateLimiter::tooManyAttempts($key, $maxAttempts)) {
$seconds = RateLimiter::availableIn($key);
abort(429, "Too many requests. Try again in {$seconds} seconds.");
}
RateLimiter::hit($key, $decayMinutes * 60);
$response = $next($request);
$response->headers->set('X-RateLimit-Limit', $maxAttempts);
$response->headers->set('X-RateLimit-Remaining', $maxAttempts - RateLimiter::attempts($key));
return $response;
}
}
出力:
// 実行成功
6. ミドルウェアパラメータ
(1) パラメータの渡し方
// パラメータ付きルート定義
Route::middleware('role:admin,super_admin')
->get('/admin/dashboard', [AdminController::class, 'dashboard']);
// ミドルウェアでパラメータを受け取る
class CheckRole
{
public function handle(Request $request, Closure $next, string ...$roles): Response
{
if (!in_array($request->user()->role, $roles)) {
abort(403, 'Insufficient role. Required: ' . implode(', ', $roles));
}
return $next($request);
}
}
(2) throttleパラメータ
// パラメータ付きビルトインthrottle
Route::middleware('throttle:60,1')->group(function () {
// 1分間に60リクエスト
});
Route::middleware('throttle:10,1')->group(function () {
// 1分間に10リクエスト (重いエンドポイント用)
});
// カスタムレートリミッター
RateLimiter::for('api', function (Request $request) {
return $request->user()
? Limit::perMinute(60)->by($request->user()->id)
: Limit::perMinute(10)->by($request->ip());
});
RateLimiter::for('tenant-api', function (Request $request) {
$tenant = app()->make(Tenant::class);
$plan = $tenant->subscription?->plan;
return Limit::perMinute(match ($plan->slug ?? 'starter') {
'enterprise' => 300,
'pro' => 120,
default => 60,
})->by($tenant->id);
});
| パラメータフォーマット | 説明 | 例 |
|---|---|---|
middleware:p1 |
単一パラメータ | role:admin |
middleware:p1,p2 |
複数パラメータ | role:admin,editor |
throttle:max,decay |
スロットリングパラメータ | throttle:60,1 |
(1) ▶ サンプル:ShopMetricsサブスクリプションベースのレート制限
// app/Providers/AppServiceProvider.php
public function boot(): void
{
RateLimiter::for('tenant-api', function (Request $request) {
$tenant = app()->make(Tenant::class);
$plan = $tenant->plan ?? null;
return Limit::perMinute(match ($plan->slug ?? 'starter') {
'enterprise' => 600,
'pro' => 120,
default => 60,
})->by($tenant->id)->response(function () {
return response()->json([
'message' => 'Rate limit exceeded. Upgrade your plan for higher limits.',
'upgrade_url' => route('pricing'),
], 429);
});
});
}
// routes/api.php
Route::middleware(['auth:sanctum', 'tenant', 'throttle:tenant-api'])
->prefix('v1')
->group(function () {
Route::apiResource('shops', Api\ShopController::class);
});
出力:
// 実行成功
7. ミドルウェアの順序と優先度
(1) デフォルトの優先度
ミドルウェアは登録された順序で実行されますが, 特定のミドルウェアは先に実行される必要があります (例:CORSは認証の前に処理される必要があります)。
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->priority([
\Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class,
\Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TenantResolve::class, // CORSの後に実行する必要がある
\App\Http\Middleware\Authenticate::class,
]);
})
(2) 終了可能ミドルウェア
// app/Http/Middleware/SendAnalyticsEvent.php
class SendAnalyticsEvent
{
public function handle(Request $request, Closure $next): Response
{
return $next($request);
}
// レスポンスがクライアントに送信された後に実行
public function terminate(Request $request, Response $response): void
{
// 非ブロッキングの分析ログ
AnalyticsEvent::create([
'tenant_id' => app()->make(Tenant::class)?->id,
'path' => $request->path(),
'method' => $request->method(),
'status' => $response->getStatusCode(),
'duration_ms' => defined('LARAVEL_START')
? round((microtime(true) - LARAVEL_START) * 1000)
: null,
]);
}
}
| ライフサイクルメソッド | タイミング | 目的 |
|---|---|---|
handle() (before) |
リクエスト受信時 | 認証, フィルタ, リクエストの変更 |
handle() (after) |
レスポンス返却時 | レスポンスヘッダーの変更 |
terminate() |
レスポンス送信後 | ログ, 統計, クリーンアップ |
(1) ▶ サンプル:ShopMetricsミドルウェア優先度設定
// bootstrap/app.php
return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware) {
// 優先度 — CORSが先, 認証がテナントの前
$middleware->priority([
\Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\TenantResolve::class,
\App\Http\Middleware\Authenticate::class,
\App\Http\Middleware\CheckRole::class,
]);
// エイリアス
$middleware->alias([
'tenant' => \App\Http\Middleware\TenantResolve::class,
'role' => \App\Http\Middleware\CheckRole::class,
'tenant.throttle' => \App\Http\Middleware\PerTenantRateLimit::class,
]);
// APIグループに追加
$middleware->appendToGroup('api', [
\App\Http\Middleware\EnsureJsonAccept::class,
]);
});
出力:
// 実行成功
8. 総合例:ShopMetricsミドルウェアアーキテクチャ
// ============================================
// 総合例: ShopMetricsミドルウェアスタック
// 対象: グローバル, ルート, グループ, パラメータ, 優先度, terminable
// ============================================
// app/Http/Middleware/TenantResolve.php
class TenantResolve
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (!$user?->tenant_id) {
abort(403, 'No tenant associated.');
}
$tenant = $user->tenant;
if ($tenant->status !== 'active') {
abort(403, 'Tenant is ' . $tenant->status);
}
app()->instance(Tenant::class, $tenant);
return $next($request);
}
}
// app/Http/Middleware/CheckRole.php
class CheckRole
{
public function handle(Request $request, Closure $next, string ...$roles): Response
{
if (!in_array($request->user()?->role, $roles)) {
abort(403, 'Required role: ' . implode('|', $roles));
}
return $next($request);
}
}
// app/Http/Middleware/EnsureSubscriptionActive.php
class EnsureSubscriptionActive
{
public function handle(Request $request, Closure $next): Response
{
$tenant = app()->make(Tenant::class);
$subscription = $tenant->subscription;
if (!$subscription || $subscription->status !== 'active') {
abort(402, 'Active subscription required.');
}
return $next($request);
}
}
// app/Http/Middleware/LogApiRequests.php
class LogApiRequests
{
public function handle(Request $request, Closure $next): Response
{
return $next($request);
}
public function terminate(Request $request, Response $response): void
{
ApiLog::create([
'tenant_id' => app()->make(Tenant::class)?->id,
'user_id' => $request->user()?->id,
'method' => $request->method(),
'path' => $request->path(),
'status' => $response->getStatusCode(),
'ip' => $request->ip(),
]);
}
}
// 完全なミドルウェアスタックを使用するルート
Route::middleware(['auth:sanctum', 'tenant', 'throttle:tenant-api', 'subscription.active'])
->prefix('v1')->group(function () {
Route::apiResource('shops', ShopController::class);
Route::middleware('role:tenant_owner,analyst')
->apiResource('analytics', AnalyticsController::class)->only(['index', 'show']);
Route::middleware('role:tenant_owner')
->apiResource('settings', SettingsController::class);
});
❓ よくある質問
terminateメソッドはいつ実行されますか?register_shutdown_functionに似ています)。レスポンスに影響を与える必要のないログや統計などの操作に適しています。terminateはユーザーをブロックしません。$next($request)を呼ばずにResponseオブジェクトを直接返します:return response()->json(['error' => 'Unauthorized'], 401);。後続のミドルウェアとコントローラは実行されません。$middleware->skipWhen(callback)を使用するか, ミドルウェア内で$request->is('api/*')を使ってルートをスキップするかを判断できます。グローバルミドルウェアにルートチェックを含めることは推奨されません。throttle:ストラテジー名として参照します。📖 まとめ
- ミドルウェアはオニオンモデルに従って実行:before → next → after
- 3つの登録方法:グローバル, ルートエイリアス, ミドルウェアグループ
- カスタムミドルウェアは
make:middlewareで作成し, エイリアス登録後に使用 - ミドルウェアパラメータはコロンで区切る:
role:admin,editor - 優先度が実行順序を決定。CORSは認証より前に実行する必要がある
terminate()はレスポンス送信後に実行。ログや統計に適している
📝 練習問題
-
基本問題 (⭐):TenantResolveミドルウェアを作成し, 認証済みユーザーからテナントを解決してコンテナに保存し, APIルートグループで使用してください。テナントがバインドされていないユーザーがAPIにアクセスしようとすると403エラーが返されることをテストしてください。
-
応用問題 (⭐⭐):PerTenantRateLimitミドルウェアを実装し, テナントIDとIPアドレスに基づいてレート制限を適用し, サブスクリプションプランに応じてレート制限を動的に設定してください (Starter:60/分, Pro:120/分, Enterprise:600/分)。
-
チャレンジ (⭐⭐⭐):完全なミドルウェア優先度システムを設計してください。CORS → TenantResolve → Auth → SubscriptionActive → CheckRole → throttleの順序で, 各ミドルウェアが正しいタイミングで実行されることを確認します。OPTIONSプレフライトリクエストが認証に傍受されないことをテストで検証してください。



