建立 Middleware 之後要跑去 app/Http/Kernel.php 註冊——這個檔案 Day 5 就講過,Laravel 11 新骨架裡已經不存在了。今天把 Middleware 的完整生命週期重新過一遍,並處理這系列目前為止「Likelihood Of Impact」等級最高的一項變動:Laravel 13 把 CSRF 防護中介層整個改名並加強了。
php artisan make:middleware EnsurePostIsPublished
// app/Http/Middleware/EnsurePostIsPublished.php
class EnsurePostIsPublished
{
public function handle(Request $request, Closure $next): Response
{
$post = $request->route('post');
if ($post && is_null($post->published_at) && ! auth()->user()?->can('viewDraft', $post)) {
abort(404);
}
return $next($request);
}
}
$next($request) 呼叫之前的程式碼在請求「進入」應用程式時執行,$next($request) 之後的程式碼(如果有)則在回應「離開」之前執行——這是 Middleware 洋蔥式架構的核心:一層層包住實際的請求處理邏輯。
用文字描述一下這個「洋蔥」比喻,會比單看程式碼更容易建立直覺:假設你有三個中介層 A、B、C 依序套用在一條路由上,一個請求進來時的實際執行順序是:
請求進來
→ A 的 $next() 之前
→ B 的 $next() 之前
→ C 的 $next() 之前
→ Controller 實際處理邏輯
→ C 的 $next() 之後
→ B 的 $next() 之後
→ A 的 $next() 之後
回應送出

這代表最先註冊的中介層,最後才會處理回應——如果你的中介層邏輯需要用到「回應已經產生」這個時機點(例如 Day 15 提過的記錄請求處理耗時),要寫在 $next($request) 之後:
class LogRequestDuration
{
public function handle(Request $request, Closure $next): Response
{
$start = microtime(true);
$response = $next($request); // 這一行才是實際執行 Controller 邏輯的地方
$duration = microtime(true) - $start;
Log::info('請求處理完成', ['duration_ms' => round($duration * 1000)]);
return $response;
}
}
舊寫法是在 app/Http/Kernel.php 的四個陣列裡註冊:$middleware(全域)、$middlewareGroups(web/api 群組)、$routeMiddleware(可指名套用)、$middlewarePriority(執行順序)。現在全部收斂進 bootstrap/app.php 的 withMiddleware() 閉包:
// bootstrap/app.php
use App\Http\Middleware\EnsurePostIsPublished;
use App\Http\Middleware\LogRequestDuration;
use Illuminate\Foundation\Configuration\Middleware;
->withMiddleware(function (Middleware $middleware) {
// 對應原本的 $middleware(全域套用到每個請求)
$middleware->append(LogRequestDuration::class);
// 對應原本的 $middlewareGroups['web'],用 append/prepend 增減群組內容
$middleware->web(append: [
EnsurePostIsPublished::class,
]);
// 對應原本的 $routeMiddleware,指名套用到特定路由
$middleware->alias([
'published' => EnsurePostIsPublished::class,
]);
// 對應原本的 $middlewarePriority
$middleware->priority([
\Illuminate\Cookie\Middleware\EncryptCookies::class,
\Illuminate\Session\Middleware\StartSession::class,
EnsurePostIsPublished::class,
]);
})
設定好別名之後,套用到路由的方式完全沒變:
Route::get('/posts/{post}', [PostController::class, 'show'])->middleware('published');
除了範例用到的 append()/web()/alias()/priority(),這個閉包還提供其他實用方法:
->withMiddleware(function (Middleware $middleware) {
$middleware->prepend(SomeMiddleware::class); // append() 的反面,插到最前面
$middleware->api(prepend: [ThrottleApiRequests::class]); // 對應 api 群組(跟 web() 是同一組方法族)
// 定義一個全新的自訂群組(不是 Laravel 預設的 web/api)
$middleware->appendToGroup('custom-group', [
SomeMiddleware::class,
AnotherMiddleware::class,
]);
// web()/api() 也接受 remove、replace 具名參數,不需要額外的獨立方法
$middleware->web(
remove: [SomeDefaultMiddleware::class],
replace: [OldMiddleware::class => NewMiddleware::class],
);
});
replace 這個具名參數特別實用——如果你想客製化 Laravel 內建某個中介層的行為(例如自訂維護模式頁面的邏輯),不需要整個移除再重新設計整套流程,用一個實作相同介面的自訂類別替換掉原本的即可,其餘設定(優先順序、群組成員關係)都會自動沿用。如果你想完全重新定義 web/api 這兩個預設群組本身包含哪些中介層(而不只是增減),改用 $middleware->group('web', [...]) 整組覆寫。
Laravel 10 的新專案預設會有九個中介層檔案(處理受信任的代理伺服器、字串修剪、CSRF 驗證等)。Laravel 11 起這些實作邏輯移進框架本體,不再以獨立檔案出現在 app/Http/Middleware 目錄裡,需要客製化行為時改用 withMiddleware() 提供的專用方法,例如 $middleware->trimStrings(except: [...])、$middleware->preventRequestForgery(except: [...])。功能不受影響,純粹是讓新專案的檔案數量更精簡。
Middleware 可以接受額外參數,實務上常見於角色/權限判斷:
class EnsureUserHasRole
{
public function handle(Request $request, Closure $next, string $role): Response
{
if (! $request->user()?->hasRole($role)) {
abort(403);
}
return $next($request);
}
}
Route::delete('/posts/{post}', [PostController::class, 'destroy'])
->middleware('role:admin');
冒號後面的字串會依序對應到 handle() 方法裡 $next 之後的參數,多個參數用逗號分隔(role:admin,editor)。
前面提過寫在 $next($request) 之後的程式碼,會在回應「送給客戶端之前」執行——但有些情境你會希望邏輯是在回應真正送出去之後才跑(例如寫一段耗時的統計記錄邏輯,不想讓使用者多等這段時間)。這種情境用「可終止的中介層」(Terminable Middleware):
class LogRequestAfterResponse
{
public function handle(Request $request, Closure $next): Response
{
return $next($request);
}
public function terminate(Request $request, Response $response): void
{
// 回應已經送給客戶端之後才執行,不會拖慢使用者收到回應的時間
Log::info('請求已完成', ['status' => $response->getStatusCode()]);
}
}
只要中介層類別除了 handle() 還定義了 terminate() 方法,Laravel 會自動偵測並在適當時機呼叫,不需要額外設定。這在需要做「使用者不需要等待的收尾工作」時很實用,跟 Day 19 會講到的 Queue 概念是類似的思路——只是 Terminable Middleware 是「回應送出後、同一個 PHP 行程結束前」執行,Queue 則是完全交給背景行程非同步處理,兩者適合的情境深淺不同。
這是這系列目前遇到的第一個「Likelihood Of Impact: High」等級變動。CSRF(Cross-Site Request Forgery,跨站請求偽造)防護一直是 Laravel 表單安全的核心機制——Day 12 提到的 @csrf 指令,背後靠的就是這個中介層驗證。
Laravel 13 把這個中介層從 VerifyCsrfToken 更名為 PreventRequestForgery,同時改成「兩層防護」的設計。這裡的順序值得講清楚,因為跟直覺相反:
Sec-Fetch-Site 標頭。現代瀏覽器會自動在每個請求帶上這個標頭,標示請求來自同源(same-origin)、同站(same-site)還是跨站。如果判定為同源,請求直接放行,完全不驗證 token。Sec-Fetch-Site 只在安全連線下才會被送出)。換句話說,token 驗證從「唯一防線」變成了「後備防線」,而不是在 token 之上多加一道檢查。實務影響:本機開發如果跑在 http:// 上,走的其實還是原本的 token 那條路。

排除特定 URI 的設定方法也跟著改名成 preventRequestForgery():
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->preventRequestForgery(except: [
'webhooks/stripe',
'line/webhook', // Day 29 的 LINE Webhook 也需要排除
]);
})
另外兩個具名參數是 Laravel 13 才有的新選項:
// 只信任來源驗證,完全關閉 token 後備機制(失敗回 403 而不是 419)
$middleware->preventRequestForgery(originOnly: true);
// 允許子網域之間的請求(例如 dashboard.example.com 接受來自 example.com 的請求)
$middleware->preventRequestForgery(allowSameSite: true);
VerifyCsrfToken、ValidateCsrfToken 這兩個舊名稱目前仍保留為已棄用的別名,繼續能用,但如果你的程式碼裡有直接 use 這個類別(例如在測試裡用 withoutMiddleware([VerifyCsrfToken::class]) 排除 CSRF 驗證),建議更新成 PreventRequestForgery:
// tests/Feature/PostTest.php
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
public function test_can_create_post_via_webhook(): void
{
$this->withoutMiddleware(PreventRequestForgery::class)
->post('/webhooks/some-service', $payload)
->assertOk();
}
舊的設定方法名稱 validateCsrfTokens() 目前仍然可用(跟類別別名一樣是相容性保留),但 Laravel 13 文件已經全面改用 preventRequestForgery(),新專案直接用新名稱即可。
寫完自訂中介層,除了手動打路由測試,也可以直接對中介層本身寫單元測試,不需要透過完整的 HTTP 請求:
test('未發布的文章會被 EnsurePostIsPublished 擋下來', function () {
$post = Post::factory()->create(['published_at' => null]);
$response = $this->get(route('posts.show', $post));
$response->assertNotFound();
});
test('已發布的文章可以正常存取', function () {
$post = Post::factory()->create(['published_at' => now()]);
$response = $this->get(route('posts.show', $post));
$response->assertOk();
});
這種寫法本質上是 Feature Test(Day 27 會深入),透過實際發出 HTTP 請求間接驗證中介層邏輯,是實務上最常見也最貼近真實使用情境的測試方式——比起針對中介層類別寫純粹的單元測試(自己組 Request/Closure 物件呼叫 handle()),透過路由測試更能確保中介層真的正確掛載在你以為它掛載的地方。
Kernel.php 卻找不到檔案:正確位置是 bootstrap/app.php 的 withMiddleware(),這是這次改版影響最直接的地方。VerifyCsrfToken::class,升級後產生棄用警告:雖然舊名稱還能用,但既然文件已經明確建議改用新名稱,建議搜尋專案裡所有 VerifyCsrfToken/ValidateCsrfToken 的引用,逐一更新成 PreventRequestForgery,尤其是排除中介層的測試程式碼最容易被忽略。auth()->user(),卻被排在 StartSession/認證中介層前面執行,會拿到錯誤的認證狀態,遇到這類問題先檢查 priority() 或 web()/api() 群組裡的排列順序。$next() 之後,而不是 terminate():這代表使用者要多等這段收尾邏輯執行完才會收到回應,如果這段邏輯不影響回應內容本身(純粹是記錄、統計),應該用 Terminable Middleware,讓使用者不需要為它多等待。replace 替換掉框架內建中介層卻沒有完整理解原本的行為:客製化框架內建的中介層邏輯前,先仔細讀懂原本的實作在做什麼(例如 PreventRequestForgery 背後的兩層防護設計),避免替換後不小心弱化了原本的安全機制。Middleware 的建立方式(make:middleware、handle() 方法簽名)完全沒變,變的是註冊位置——從 Kernel.php 的四個陣列,整併進 bootstrap/app.php 的 withMiddleware() 閉包,並提供更語意化的方法(web()、api()、alias()、priority()、replace())取代直接操作陣列。今天也認識了洋蔥模型的實際執行順序,以及 Terminable Middleware 這個處理「回應送出後才執行」邏輯的專門機制。CSRF 中介層更名為 PreventRequestForgery 並加強來源驗證,是 Laravel 13 這次改版裡少數「Likelihood Of Impact: High」的項目,值得認真檢查你的專案裡有沒有寫死舊類別名稱的地方。
防患於未然 — 《周易・既濟》
Day 17 進入 Validation 驗證器:常用規則、Form Request、自訂驗證,並認識 Laravel 13 新增的 array_keys 規則。