Wu-Hsien Yu/ 文章
文章

重構二:從模組化單體到獨立 Package

在〈重構一:從單體應用到模組化〉裡,我把一個 Laravel 單體重構成依業務領域組織的 modules/ 結構,解決了同一個業務模組的程式碼散落在多個目錄的問題,也讓 Admin、Store、Commerce 幾個子系統住進同一個專案裡。

這個結構跑了一段時間,運作良好。但隨著搬遷、重構持續推進,另一個問題浮現了。

空殼

有一天我發現,Laravel 預設的 app/ 目錄裡幾乎沒有東西了。

Controllers 搬進了各自的模組,Models 搬進了各自的業務領域,Jobs、Listeners、Notifications 全部搬走,連 User 都搬進了 Modules\Coreroutes/web.php 只剩註解,resources/views/ 只剩預設的 welcome 頁。所有的實作 — 包括 views — 都在 modules/ 裡。

Laravel 本體剩下的只有:.envbootstrap/,以及把 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:

php
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:

php
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:

php
// KhiaServiceProvider::boot()
$this->app[Kernel::class]->prependMiddleware(SetModuleConfig::class);
php
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 一種。且兩個實作的判斷條件不一致:一個認得 domainprefix,另一個只認 domain

執行順序

值得記下來的是我為什麼會走到那裡:要在 StartSession 之前,我以為就是要 global

但把 request 的實際時間軸攤開,StartSession 的位置比想像中晚得多:

Request 進來

 ├─ ① Global middleware(TrustProxies → HandleCors → ...)

 ├─ ② Router 匹配 route          ← 這一刻起,「命中哪個 group」已知

 ├─ ③ Route middleware(依 route 上宣告的順序)
 │      EncryptCookies
 │      StartSession             ← 「讀取」發生的時刻
 │      ValidateCsrfToken

 └─ ④ Controller

StartSession 不是 global middleware — 它在 'web' group 裡,屬於第 ③ 段。「在它之前」的合法區間,從 ① 一路到 ③ 段的前半。而 ③ 段的開頭,有一個 ① 段永遠沒有的東西:route 已經匹配完成,module 的身分已經確定,不需要任何解析。

我選了 ①,就需要自己從 host 把資訊補回來,但就變得很複雜。「夠早=要 global」是一個錯誤的判斷決策:順序限制只要求「在讀之前寫」,global 是過度滿足

由 route 宣告

修正的方式,是把「是哪個 module」從解析改成宣告:每個子系統註冊 routes 時,直接把自己的名字給 middleware:

php
// AdminServiceProvider
$route->middleware([SetModuleConfig::class.':admin', 'web'])
    ->name('admin.')
    ->group(__DIR__.'/../../../routes/admin.php');
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 順序正確(SetAppContextStartSession 之前);config('session.cookie')admin-session(middleware 確實跑了、也改了 config);但 response 送出的 session cookie 叫 laravel_session — 預設名字,而且沒有任何錯誤被拋出。

唯一能解釋的假設是:session store 在 middleware 執行之前就建立了。查看實際的程式碼發現 (Laravel 13):

shell
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 裡,不需要物件就能呼叫:

php
class OrderController implements HasMiddleware
{
    public static function middleware(): array
    {
        return [new Middleware('auth', except: ['index'])];
    }
}

方式二:繼承舊式 base Illuminate\Routing\Controller 清單由建構子裡的 $this->middleware(...) 寫進物件的屬性 — 建構完成之前,清單根本不存在,router 必須先把 controller 建出來才問得到。ViewControllerRedirectController 都屬於這種:

php
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。而 Storesnapshot — 建構的那一刻就先讀取 config 了,之後再也不看 config。

對 snapshot 而言,middleware 的順序就沒有意義 — 它的讀取時機是物件的建構時機,跟著 container 的 resolve 走,不跟著 pipeline 走。所以規則要改成:

  1. 寫 config,要趕在任何把 config 建構成物件之前。
  2. 如果保證不了第一條,就要連已建構的物件一起改。

我的狀況是第二種。middleware 改完 config 後,檢查 store 是否已經存在,如果存在就需要改名:

php
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);
    }
}
  1. 先用 getDrivers() 判斷,不能直接呼叫 driver() — 那會為了檢查而建構出一個新的 store,自己製造了要防範的東西。
  2. 改名,而不是重建(forgetDrivers())— Redirector 已經有舊的 store 的參考,重建會變成兩個 store:flash data 寫進舊的、StartSession 管理新的,資料被分家。改名讓所有持有者繼續指向同一個物件。

會觸發這個問題的條件,可能不一定會在 controller 裡面出現。任何在 pipeline 之前 resolve 到 session.store 的行為都有可能。所以測試時不綁定任何特定觸發者:先故意提前建出一個 store,再跑 middleware,斷言名字有被改過來:

php
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 時,每一處都要重複做對:

php
// 對
$route->middleware([SetAppContext::class.':commerce', 'web'])

// 如果寫反 — 不會有任何錯誤
$route->middleware(['web', SetAppContext::class.':commerce'])

寫反的那天,routes 照常運作、畫面正常,只是 StartSession 先跑、cookie 用回預設名稱,session 隔離又會無聲失效。

Laravel 內建了把順序從慣例升級成規則的機制 — kernel 的 middleware priority。註冊一次,全域生效:

php
$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 主體變成空殼之後,結構自己指出來的下一步。

相關文章

2026.08.04從 Migrations 走回 Schema Dumps:Laravel 內建的那條路2026.04.20Kindie - 開發日誌 012026.01.21自建物流標籤列印系統:從 Loftware 到 GoDex