重構二:從模組化單體到獨立 Package
在〈重構一:從單體應用到模組化〉裡,我把一個 Laravel 單體重構成依業務領域組織的 modules/ 結構,解決了同一個業務模組的程式碼散落在多個目錄的問題,也讓 Admin、Store、Commerce 幾個子系統住進同一個專案裡。
這個結構跑了一段時間,運作良好。但隨著搬遷、重構持續推進,另一個問題浮現了。
空殼
有一天我發現,Laravel 預設的 app/ 目錄裡幾乎沒有東西了。
Controllers 搬進了各自的模組,Models 搬進了各自的業務領域,Jobs、Listeners、Notifications 全部搬走,連 User 都搬進了 Modules\Core。routes/web.php 只剩註解,resources/views/ 只剩預設的 welcome 頁。所有的實作 — 包括 views — 都在 modules/ 裡。
Laravel 本體剩下的只有:.env、bootstrap/,以及把 Modules\ 指向 modules/src/ 的 composer.json。
當一個目錄擁有自己的 routes、views、config 與 service providers,而 Laravel 預設目錄裡什麼都不剩 — 它其實已經是一個 package,只差還沒真的拆出去。
剩下的 .env 與 bootstrap,是安裝方該提供的東西,不是實作方該擁有的。結構自己長成了 package 的形狀 — 這就是該動手的訊號。
成為 Package
於是這次重構的方向很直接:讓 modules 自立門戶,成為標準的 Composer package,之後可以安裝進任何一個 Laravel 專案。
這一步換來的不只是可以重複安裝:
- 邊界從紀律變成物理限制: 在單體裡,模組要偷偷
use App\Something只是一念之間,邊界靠自律維持;成為 package 之後,App\根本不存在,Composer 的邊界是強制的。 - 測試環境徹底獨立: 用 Orchestra Testbench 就能在沒有 Laravel 本體的情況下跑完整個測試套件,開發時用 workbench 預覽(測試資料庫的建置過程,寫在〈從 Migrations 走回 Schema Dumps〉)。
- 版本成為溝通工具: Laravel 本體升級與 package 演進功能,從此是兩條可以獨立前進的線。
結構直接沿用〈重構一〉的概念 — Apps/ 是應用層,其餘是共用的業務模組:
khia/
├── src/
│ ├── Apps/ # 應用層:每個 app 有自己的入口
│ │ ├── Admin/
│ │ │ └── AdminServiceProvider.php
│ │ ├── Commerce/
│ │ └── Pharmacy/
│ ├── Core/ # 共用模組
│ ├── Catalog/
│ └── KhiaServiceProvider.php
├── config/khia.php
├── routes/admin.php
├── resources/views/admin/
└── testbench.yaml對外只暴露一個進入點 — KhiaServiceProvider 串起所有 app:
class KhiaServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__.'/../config/khia.php', 'khia');
$this->app->register(AdminServiceProvider::class);
$this->app->register(CommerceServiceProvider::class);
$this->app->register(PharmacyServiceProvider::class);
}
}每個 app 管理自己的 routes 與 views,view 有自己的 namespace(admin::index),並透過 subdomain 分流 — admin.khia.test 進 Admin、commerce.khia.test 進 Commerce:
class AdminServiceProvider extends ServiceProvider
{
public function boot(): void
{
$domain = Config::get('khia.admin.domain');
$route = $domain ? Route::domain($domain) : Route::prefix('admin');
$route->middleware('web')
->name('admin.')
->group(__DIR__.'/../../../routes/admin.php');
$this->loadViewsFrom(__DIR__.'/../../../resources/views/admin', 'admin');
}
}到這裡都很順利,直到需要隔離子系統。
解析子系統網域
各個 app 需要隔離的 session 與不同的 auth model。Admin 登入的是員工、Commerce 登入的是客戶,session cookie 不該共用。這帶來一個限制:session.cookie 必須在 StartSession middleware 讀取它之前改好 — 等 session 已經用預設名稱 laravel_session 開起來,再改 config 就沒有意義了。
從 host 解析
既然要趕在 StartSession 之前,我的直覺是 global middleware — 把 SetModuleConfig 放到整條流程的最前面,用 host 反推現在是哪個 module:
// KhiaServiceProvider::boot()
$this->app[Kernel::class]->prependMiddleware(SetModuleConfig::class);class SetModuleConfig
{
public function handle(Request $request, Closure $next): Response
{
$module = self::resolveModule($request->getHost());
if ($module === null) {
throw new NotFoundHttpException('Unknown host: '.$request->getHost());
}
Config::set('session.cookie', Config::get("khia.{$module}.session.cookie"));
if ($model = Config::get("khia.{$module}.auth.providers.users.model")) {
Config::set('auth.providers.users.model', $model);
}
return $next($request);
}
public static function resolveModule(string $host): ?string
{
foreach (Config::get('khia', []) as $name => $config) {
if (($config['domain'] ?? null) === $host) {
return (string) $name;
}
}
return null;
}
}看起來很合理:夠早、集中一處,還順便擋掉了不認識的 host。直到發現下列問題:
1. prefix fallback 永遠不會執行。 route 註冊時有一條退路 — 沒設定 domain 的時候,就改用 Route::prefix('admin'),讓沒有 DNS 設定的開發環境也能走 localhost/admin/...。但 domain 是 null 的話,resolveModule() 同樣比對不到任何 module,middleware 在進入 routing 之前就先拋出 NotFoundHttpException — 這條退路永遠沒有機會被走到。
2. 放行也不對。 那不拋例外、讓不認識的 host 通過呢?host 是 localhost 時 $module 是 null,session 與 auth 的設定都不會套用 — admin 的 routes 跑起來了,用的卻是預設的 session cookie 和預設的 auth model。畫面一切正常,隔離卻沒有生效。這種安靜的半套狀態,比直接 404 更危險。
3. 404 是重複的。 subdomain 模式下,不認識的 host 本來就比對不到任何 Route::domain(...) 的 route,router 自己就會回 404。middleware 那段 NotFoundHttpException,等於把 router 已有的行為再寫一次。
原因
Router 在比對 Route::domain('admin.khia.test') 的那一刻,其實已經回答了「這是 admin 的 request」— host 比對本來就是它的匹配條件。而 SetModuleConfig 趕在 routing 之前,用 resolveModule() 把同一個問題再判斷一次。系統裡於是有兩種實作:router 一種、middleware 一種。且兩個實作的判斷條件不一致:一個認得 domain 與 prefix,另一個只認 domain。
執行順序
值得記下來的是我為什麼會走到那裡:要在 StartSession 之前,我以為就是要 global。
但把 request 的實際時間軸攤開,StartSession 的位置比想像中晚得多:
Request 進來
│
├─ ① Global middleware(TrustProxies → HandleCors → ...)
│
├─ ② Router 匹配 route ← 這一刻起,「命中哪個 group」已知
│
├─ ③ Route middleware(依 route 上宣告的順序)
│ EncryptCookies
│ StartSession ← 「讀取」發生的時刻
│ ValidateCsrfToken
│
└─ ④ ControllerStartSession 不是 global middleware — 它在 'web' group 裡,屬於第 ③ 段。「在它之前」的合法區間,從 ① 一路到 ③ 段的前半。而 ③ 段的開頭,有一個 ① 段永遠沒有的東西:route 已經匹配完成,module 的身分已經確定,不需要任何解析。
我選了 ①,就需要自己從 host 把資訊補回來,但就變得很複雜。「夠早=要 global」是一個錯誤的判斷決策:順序限制只要求「在讀之前寫」,global 是過度滿足。
由 route 宣告
修正的方式,是把「是哪個 module」從解析改成宣告:每個子系統註冊 routes 時,直接把自己的名字給 middleware:
// AdminServiceProvider
$route->middleware([SetModuleConfig::class.':admin', 'web'])
->name('admin.')
->group(__DIR__.'/../../../routes/admin.php');class SetModuleConfig
{
public function handle(Request $request, Closure $next, string $module): Response
{
if ($cookie = Config::get("khia.{$module}.session.cookie")) {
Config::set('session.cookie', $cookie);
}
if ($model = Config::get("khia.{$module}.auth.providers.users.model")) {
Config::set('auth.providers.users.model', $model);
}
return $next($request);
}
}陣列裡它排在 'web' 前面,group 展開後就跑在 StartSession 之前 — 順序一樣有滿足(Laravel 的 $middlewarePriority 只重排優先清單內的 middleware,不在清單上的維持原位),且三個問題同時消失:
- Domain 與 Prefix 都會成立。 module 的身分綁在 route group 的宣告上,跟 request 長什麼樣子無關 —
Route::domain匹配到是 admin,Route::prefix匹配到也是 admin。 - 沒有半套狀態。 middleware 有執行,就代表 route 比對完成、module 已經確定,也就一定包括了設定。
- 404 還給 router。 不認識的 host 比對不到任何 route,router 自然會回 404,不必再寫一次。
host 解析的邏輯就整個不需要了。
意外
移除解析 host 邏輯後,我做了一個小調整:把 admin 首頁的 route 從 closure 換成 Route::view — closure route 無法被 route:cache 序列化,而 Route::view 是 framework 內建、可快取的 ViewController。
但是,這個調整導致了測試的失敗:middleware 順序正確(SetAppContext 在 StartSession 之前);config('session.cookie') 是 admin-session(middleware 確實跑了、也改了 config);但 response 送出的 session cookie 叫 laravel_session — 預設名字,而且沒有任何錯誤被拋出。
唯一能解釋的假設是:session store 在 middleware 執行之前就建立了。查看實際的程式碼發現 (Laravel 13):
Router::runRouteWithinStack() ← Router.php:816
└─ gatherRouteMiddleware($route) ← pipeline 還沒開始
└─ Route::controllerMiddleware() ← Route.php:1121
│ 三種模式:實作 HasMiddleware 的走靜態讀取;
│ 舊式 base(有 getMiddleware(),ViewController 屬於這個)
│ 的 middleware 是 instance method,router 為了知道
│ 完整清單,必須先拿到 controller 實體 —
└─ $this->getController() ← Route.php:1140 呼叫
└─ $this->container->make($class) ← Route.php:297
│ 真正的 new 發生在 Container::build() 裡 —
│ container 用 reflection 解析建構子的相依參數、
│ 一層層自動 resolve,這正是連鎖反應的起點:
└─ ViewController::__construct(ResponseFactory $response)
└─ ResponseFactory 需要 Redirector
└─ 'redirect' binding 裡有一行:
if (isset($app['session.store']))
$redirector->setSession($app['session.store']);
└─ 'session.store' binding = SessionManager::driver()
└─ new Store($config->get('session.cookie'), ...)
→ 名字凍結,預設的名稱 = 'laravel_session' ← 被 snapshot 住controller 有三種方式宣告自己的 middleware,router 對每一種的讀取方式完全不同:
方式一:實作 HasMiddleware interface(Laravel 11+ 的新式寫法)。 清單放在 static method 裡,不需要物件就能呼叫:
class OrderController implements HasMiddleware
{
public static function middleware(): array
{
return [new Middleware('auth', except: ['index'])];
}
}方式二:繼承舊式 base Illuminate\Routing\Controller。 清單由建構子裡的 $this->middleware(...) 寫進物件的屬性 — 建構完成之前,清單根本不存在,router 必須先把 controller 建出來才問得到。ViewController、RedirectController 都屬於這種:
class OrderController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
}方式三:兩者皆非的 plain controller。 既沒有實作 HasMiddleware、也沒有繼承舊式 base — Laravel 11 之後 make:controller 產生的就是這種(骨架的 base controller 只是一個普通的 abstract class)。對這種 class,唯一可能藏著 middleware 的地方是 PHP attributes(#[Middleware('auth')] 這種寫在 class 或 method 上的中繼資料標註),router 用 reflection 讀取標註即可,同樣不需要建立物件。
| 宣告方式 | Router 的讀取方式 | 會提前建構嗎 |
|---|---|---|
HasMiddleware(static method) | 呼叫 static method | 不會 |
| 舊式 base(建構子宣告) | 先建構,再讀屬性 | 會 |
| plain(只可能有 attributes) | reflection 讀標註 | 不會 |
所以只要是繼承 Illuminate\Routing\Controller 的 controller,為了取得在建構子裡的 middleware,都會提前把 session store 建構出來。在這之後 pipeline 才開始跑,此時 SetAppContext 把 config 改成 admin-session,但 SessionManager 快取了 driver 實體,StartSession 拿到的是那個早已建構完成、名字凍結的舊 store。
讀取方式
要寫在 StartSession 讀取之前有一個假設:所有讀取方都是用到的時候才去 config 查詢 session.cookie。而 Store 是snapshot — 建構的那一刻就先讀取 config 了,之後再也不看 config。
對 snapshot 而言,middleware 的順序就沒有意義 — 它的讀取時機是物件的建構時機,跟著 container 的 resolve 走,不跟著 pipeline 走。所以規則要改成:
- 寫 config,要趕在任何把 config 建構成物件之前。
- 如果保證不了第一條,就要連已建構的物件一起改。
我的狀況是第二種。middleware 改完 config 後,檢查 store 是否已經存在,如果存在就需要改名:
protected function renameInstantiatedSessionStore(): void
{
if ($this->session->getDrivers() === []) {
return;
}
$store = $this->session->driver();
$cookie = Config::get('session.cookie');
if ($store instanceof Session && is_string($cookie) && $cookie !== '') {
$store->setName($cookie);
}
}- 先用
getDrivers()判斷,不能直接呼叫driver()— 那會為了檢查而建構出一個新的 store,自己製造了要防範的東西。 - 改名,而不是重建(
forgetDrivers())—Redirector已經有舊的 store 的參考,重建會變成兩個 store:flash data 寫進舊的、StartSession管理新的,資料被分家。改名讓所有持有者繼續指向同一個物件。
會觸發這個問題的條件,可能不一定會在 controller 裡面出現。任何在 pipeline 之前 resolve 到 session.store 的行為都有可能。所以測試時不綁定任何特定觸發者:先故意提前建出一個 store,再跑 middleware,斷言名字有被改過來:
it('renames a session store instantiated before the middleware ran', function () {
config(['session.driver' => 'array', 'session.cookie' => 'laravel-session']);
app('session')->driver();
applyAppContext('admin');
expect(app('session')->driver()->getName())->toBe('admin-session');
});慣例與規則
另外,也想到一個問題:「SetAppContext 要排在 'web' 前面」這件事,是靠什麼保證的?
答案是:靠每個 app 的 route group 自己寫對。這是慣例 — 未來 Commerce、Pharmacy 註冊 routes 時,每一處都要重複做對:
// 對
$route->middleware([SetAppContext::class.':commerce', 'web'])
// 如果寫反 — 不會有任何錯誤
$route->middleware(['web', SetAppContext::class.':commerce'])寫反的那天,routes 照常運作、畫面正常,只是 StartSession 先跑、cookie 用回預設名稱,session 隔離又會無聲失效。
Laravel 內建了把順序從慣例升級成規則的機制 — kernel 的 middleware priority。註冊一次,全域生效:
$kernel->addToMiddlewarePriorityBefore(StartSession::class, SetAppContext::class);框架組裝每條 route 的 middleware stack 時,SortedMiddleware 會依 priority 清單重排,就算某個 app 寫反了,也會被排回 StartSession 之前。
Module 與 Tenant:什麼時候才該從 host 解析
有趣的是,「global middleware 從 host 解析」本身是業界的標準做法 — Spatie 的 laravel-multitenancy 就是用 DomainTenantFinder 從 host 找出當前的 tenant。那為什麼它用是對的,我用是錯的?
因為 tenant 是資料,module 是程式碼。
Tenant 是資料庫裡的一筆 row,在執行期被建立 — 今天簽下一個新客戶,多一筆記錄,程式碼一行都不用改。所有 tenant 共用同一份 routes,route 結構裡沒有任何一行「知道」tenant 是誰。這個資訊不存在於程式碼裡,只能從 request 解析出來,再拿去查資料庫。這也是為什麼 Spatie 的 finder 提供了多種方式:host、header、path,都可以)— 從 request 的哪裡解析本來就是 tenant 問題的一部分。
Module 卻剛好相反。Admin 的 routes 只屬於 Admin,這是一開始就決定的事實,根本不需要解析。
判斷準則:
- 我能為每一個 X 寫一個專屬的 route group 嗎?能 → 表示是開發時期的程式碼結構,例如:module)→ 在 group 上宣告,往下傳遞。
- 如果不能 → 表示是執行時期的資料,例如:tenant → 只能從 request 解析,交給 finder。
最後
〈重構一〉的結尾寫過:架構是演進的結果,而非預先設計的完美藍圖。成為 package 不是當初的規劃,而是 Laravel 主體變成空殼之後,結構自己指出來的下一步。