iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
Modern Web

我推的Laravel S2!系列 第 22

我推的Laravel S2|Day 21:Request Lifecycle:一個請求的完整旅程

  • 分享至 

  • xImage
  •  

一句話破題

你大概看過那張經典的請求生命週期圖:public/index.phpapp/Http/Kernel.php → Service Provider → 路由分派 → 結束程序。問題是圖裡有個關鍵節點(Kernel.php)在 Laravel 11 已經不存在了,今天用 Day 5、16、18 建立的新骨架知識,把這張圖整個重畫一遍。

起點:public/index.php

所有請求的入口,這點完全沒變:

// public/index.php(簡化示意)
define('LARAVEL_START', microtime(true));

require __DIR__.'/../vendor/autoload.php';

$app = require_once __DIR__.'/../bootstrap/app.php';

$app->handleRequest(Request::capture());

這個檔案做三件事:載入 Composer 的自動載入器、載入 bootstrap/app.php 組出應用程式實例、把目前的 HTTP 請求交給應用程式處理。

Request::capture() 這一步值得多看一眼——它把 PHP 全域的 $_GET/$_POST/$_SERVER/$_COOKIE 這些超級全域變數,包裝成 Day 13 已經很熟悉的 Illuminate\Http\Request 物件。這代表你在 Controller 裡拿到的 $request,本質上是這些原生 PHP 全域變數的一層物件導向包裝——理解這一點,有助於解釋為什麼 Laravel 的 Request 物件能提供 input()/query()(Day 13)這些比直接操作 $_GET/$_POST 更安全、更一致的存取方式:所有的邊界情況(例如同名參數同時出現在 GET 跟 POST)都在這層包裝裡被統一處理掉了。

第二步:bootstrap/app.php 組裝應用程式

這是這次改版影響最大的環節。Day 5 認識過的 Application::configure() 鏈式呼叫,在這一步真正發揮作用——它組裝出一個完整設定好路由規則、中介層行為、例外處理邏輯的應用程式實例:

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(/* Day 7 定義的路由檔案 */)
    ->withMiddleware(/* Day 16 定義的中介層規則 */)
    ->withExceptions(/* Day 18 定義的例外處理規則 */)
    ->create();

舊圖在這一步標的是「進入核心(HTTP / Console Kernels)」,由獨立的 Kernel 類別負責組裝這些行為。新版把組裝過程改成宣告式的鏈式呼叫,職責上是同一件事——把整個應用程式「怎麼回應請求」的規則準備好——只是不再需要一個獨立的 Kernel 類別來承接。

這一步實際上也是 Service Container(Day 23 會完整深入)第一次真正登場的地方:Application::configure() 回傳的 $app 物件本身就是容器實例,後續每一個 Facade 呼叫(Route::Log::Cache:: 等等)背後都是向這個容器詢問「幫我解析出對應的服務」。今天先建立這個印象,Day 23 會把「Facade 到底是什麼、為什麼看起來像靜態方法但其實不是」講清楚。

第三步:Service Provider 啟動

應用程式實例組好後,會依序執行所有已註冊 Service Provider 的 register()全部 Provider 的 register() 都跑完之後,才會依序執行每個 Provider 的 boot()。這個「先全部 register,再全部 boot」的兩階段設計,正是常見錯誤裡提到的「不該在 register() 用到其他 Provider 綁定的服務」這條規則的根本原因——當 A 的 register() 在執行時,你完全無法保證 B 的 register() 是不是已經跑過了,唯一能確保「所有服務都已綁定完成」的時間點,是進入 boot() 階段之後。

Day 5 提過,新專案預設只有一個 AppServiceProvider,你在 Day 7(RateLimiter)、Day 9(Route::pattern())寫進 AppServiceProvider::boot() 的設定,就是在這個階段被執行的。如果專案有透過 bootstrap/providers.php 註冊其他自訂 Provider(Day 23 會示範怎麼建立),也是在這一步依序啟動。

Deferred Provider:一個例外情況

Day 23 會提到,有些 Provider 可以標記成「延遲載入」(實作 DeferrableProvider 介面),這類 Provider 的 register()/boot() 不會在應用程式啟動時就執行,而是等到真的有程式碼嘗試解析它綁定的服務時才觸發。這是一個效能優化機制——如果某個 Provider 只服務一個很少被用到的功能,沒必要讓每個請求都承擔載入它的成本。今天先知道這個例外存在即可,不影響對主流程的理解。

第四步:路由分派與中介層

應用程式接著會比對請求的 URL/HTTP 動詞,找到對應的路由(Day 7),並依序執行套用在這條路由上的中介層堆疊(Day 16)——先進先出的洋蔥式架構:

請求 → 全域中介層 → web/api 群組中介層 → 路由指定的中介層 → Controller

每一層中介層的 handle() 方法裡,$next($request) 呼叫之前是請求「進入」時執行的邏輯,中介層堆疊全部通過後才會真正進到 Controller 方法(Day 13)。如果任何一層中介層直接回傳了 Response(沒有呼叫 $next(),例如 Day 16 示範的權限判斷不通過直接 abort()),請求就不會繼續往下傳遞。

路由比對之前:找不到符合的路由會發生什麼

如果請求的 URL 完全比對不到任何已定義的路由,Laravel 會拋出 NotFoundHttpException,交給 Day 18 講過的例外處理機制轉換成 404 回應——這代表「路由找不到」跟「Controller 邏輯裡明確 abort(404)」走的是同一套後續處理路徑,只是觸發的時間點不同(前者發生在路由分派階段,後者發生在 Controller 執行階段)。

第五步:Controller 處理、可能觸發例外

Controller 方法執行、跟 Model 互動、回傳 Response 或拋出例外。如果拋出例外,會被 Day 18 設定的 withExceptions() 規則接手,決定要記錄什麼、回傳什麼樣的錯誤回應。

第六步:回應往外傳,結束程序

Controller(或例外處理邏輯)產生的 Response,會反向通過原本進來時的中介層堆疊——這是中介層 handle() 方法裡 $next($request) 呼叫之後的程式碼實際發揮作用的時機(例如 Day 15 提到,某些中介層會在這個階段記錄請求處理花費的時間)。最後 public/index.php 呼叫 send() 把回應內容真正輸出給瀏覽器/客戶端。

回應送出之後:terminate 階段

Day 16 提過的 Terminable Middleware,實際發生在 send() 之後——public/index.php 在把回應內容送給瀏覽器之後,還會呼叫一次 $kernel->terminate(),這時候才會執行所有中介層的 terminate() 方法(如果有定義的話)。這是整個請求生命週期裡最後才執行的階段,也是唯一一個「使用者已經拿到回應、不需要再等待」的時間點——這正是為什麼這個階段特別適合放「使用者不需要等待結果」的收尾邏輯。

Console 生命週期:跟 HTTP 不一樣的另一條路

今天講的整套流程都是針對「透過瀏覽器/API 客戶端進來的 HTTP 請求」,但 Day 25 會講到的 Artisan 指令(php artisan xxx)走的是一條平行、但概念相似的路徑:

php artisan xxx
  → bootstrap/app.php(同一份組裝邏輯,但走 Console Kernel 而非 HTTP Kernel)
  → Service Provider 啟動(跟 HTTP 請求共用同一套 register()/boot())
  → Console Kernel 找到對應的指令類別
  → 執行指令的 handle() 方法
  → 輸出結果、結束程序

HTTP 與 Console 兩條路徑共用 bootstrap/app.php 的 Provider 啟動階段,之後分岔:HTTP 有路由比對與中介層堆疊、可用 Session/語系;Console 只解析 $signature,沒有路由與中介層,也不會繼承 HTTP 階段的狀態

關鍵差異在於沒有「路由」跟「中介層」這兩個概念(Console 指令有自己的簽名解析機制,Day 25 會講的 $signature),但 Service Provider 的啟動邏輯是共用的——這也是為什麼 Day 20 提醒過,Job/Console 情境下不會有 HTTP Middleware 設定的語系或 Session 資料,因為它們根本不會經過 HTTP 請求的中介層堆疊。理解「HTTP 跟 Console 共用應用程式組裝邏輯,但之後走不同路徑」這件事,能幫助你判斷一段程式碼「在背景指令裡執行時會不會跟你以為的行為不一樣」。

完整請求生命週期:public/index.php → bootstrap/app.php 組裝 → Service Provider 啟動 → 路由比對 → 中介層堆疊(進入)→ Controller → 中介層堆疊(反向)→ send() → terminate()

把前面六個步驟串成一張圖,能更清楚看出「進入」跟「反向送出」是同一套中介層堆疊,只是方向相反;terminate() 則是整條路徑上唯一一個「使用者已經拿到回應」之後才執行的階段。

新舊對照總覽

步驟 Laravel 10 的說法 現在(Laravel 13)
入口 public/index.php 不變
核心組裝 app/Http/Kernel.php / app/Console/Kernel.php bootstrap/app.phpApplication::configure() 鏈式呼叫
Provider 啟動 五個預設 Service Provider 依序 register()/boot() 一個 AppServiceProvider(加上任何 bootstrap/providers.php 註冊的自訂 Provider),一樣是先全部 register() 再全部 boot()
路由/中介層 $routeMiddleware/$middlewareGroups 陣列定義的堆疊 withMiddleware() 定義的堆疊,概念相同
例外處理 app/Exceptions/Handler.php withExceptions()
結束程序 Response 反向通過中介層、index.php 呼叫 send() 完全不變,另有 terminate() 階段處理收尾邏輯

用這張圖排查問題:一個實用的思考框架

理解整條生命週期最大的實際價值,是遇到「這段程式碼的行為跟我預期不一樣」時,能快速定位問題發生在哪個階段。幾個常見的排查情境:

  • 「我在這裡讀不到 Session/認證狀態」 → 檢查你的程式碼是不是執行在 StartSession/認證中介層之前(例如寫進了排序過早的全域中介層,或錯誤地放進 Service Provider 的 register())。
  • 「我改的設定好像沒生效」 → 檢查是不是被 Day 6 的 config:cache、Day 7 的 route:cache 快取住了,這些快取會讓生命週期的組裝階段直接讀快取檔,跳過你剛改的原始碼。
  • 「Job 裡讀不到我在 Controller 設定的東西」 → 前面 Console 生命週期那節提過,Job/Console 是完全獨立的一條路徑,Controller 階段設定的任何「只存在於這次 HTTP 請求生命週期內」的狀態(Session、App::setLocale())都不會自動延續過去。

常見錯誤與踩雷點

  • 以為 Service Provider 的 register() 可以用到其他 Provider 綁定的服務register() 階段只能做「把東西綁進容器」這件事,不該在這時候去解析(use)容器裡的服務,因為你無法保證其他 Provider 的 register() 是不是已經執行完——需要用到已綁定服務的邏輯要寫在 boot(),這個階段所有 Provider 的 register() 都已經跑完了。
  • 把應該在中介層做的判斷寫進 Controller,導致重複程式碼:如果一個檢查邏輯(例如「文章必須已發布才能看」)套用在多個 Controller 方法上,用中介層在請求生命週期早期就擋下來,比在每個方法開頭重複寫 if 判斷更符合這個生命週期模型的設計精神。
  • 誤以為 withExceptions() 的例外處理發生在 Controller 執行「之前」:例外處理是在例外真正被拋出的當下才介入,不是請求生命週期裡的一個固定階段,這跟中介層、Provider 啟動這種「一定會依序執行」的階段不同。
  • 在 Job/Console 情境裡假設 HTTP 請求生命週期建立的狀態依然存在:前面提過的重點,背景執行環境是完全獨立的一條路徑,不會自動繼承 HTTP 請求階段設定的語系、Session 等狀態。

小結

請求生命週期的核心邏輯——入口、核心組裝、Provider 啟動、路由與中介層、Controller 處理、回應往外傳、terminate() 收尾——這個框架完全沒變,變的只是「核心組裝」這一步的實作方式,從獨立的 Kernel 類別改成 bootstrap/app.php 的宣告式設定。今天也補上了 Console 生命週期跟 HTTP 生命週期的差異、以及怎麼用這張圖當作實際排查問題的思考框架。理解這張圖,能幫助你判斷任何一段邏輯「應該寫在生命週期的哪個階段」。

萬變不離其宗 — 中國諺語

明日預告

Day 22 把 Coding Style/PSR 跟 OOP/SOLID 合併成一篇扎實的軟體工程基礎回顧。


上一篇
我推的Laravel S2|Day 20:多語系 Localization
系列文
我推的Laravel S2!22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言