Guardrail Test:用 PHPUnit 防止架構回退,13 個 Legacy Blade 清零紀錄


把 Legacy PHP 改寫成 Laravel/Blade 時,最怕的不是「改不完」,而是「改完後同事繼續用舊 pattern 寫」。

抽好的 Service、清理過的 inline DB 查詢、移到 Controller 的資料載入邏輯——只要沒有強制力,下個 PR 又會回到原樣。

這篇拆解一個真實的 PHPUnit guardrail test:309 行、6 個 test 方法、跨 Controller + Blade 雙向掃描。L350 milestone 引入後,第一次跑就抓到 L336 留的 inline 查詢;L355-L367 用它清掉 13 個 grandfathered blade,最終 LEGACY_NEW_DB_BLADE_ALLOWLIST 變成空 array。


TL;DR

項目內容
Test 名稱AppSystemFormServiceConventionTest
MilestoneL350 引入、L354-L367 擴充與清零
行數309 行 PHP
Test 方法數7 個(Controller / Blade / Scaffold / Helper 多向掃描)
被擋的模式5 種:inline permission JOIN / 7z glob / new DB() / formgroup query / scaffold placeholder
首次發現L350 第一次跑就抓到 L336 ModuleDayCareController 違規
清零過程LEGACY_NEW_DB_BLADE_ALLOWLIST 從 13 → 0(L355-L367)
技術背景Laravel 11 嵌入式架構、漸進式 Migration

背景:Phase B 完成後的下一個問題

Phase B 完成了大事:把 Legacy 的 module_a/ajax/ 51 個檔案、408 個 handlers 全部轉入 Laravel——產出 545 routes、103 Controllers、10 Services、1 Artisan command。

但 Phase B 結束時遇到一個結構性問題:

「Service 抽出來了,但 Controller 不一定會用。新 Controller 寫完一個月,又回去 inline 寫 4-table JOIN 了。」

這不是設計問題,是強制力問題。Code review 抓得到,但人會累、會忘、會通融。

L350 milestone 的解法:用 PHPUnit test 把架構規則變成 CI 失敗


4 個共用 Service 的背景

在寫 guardrail 之前,Phase C 先抽了 4 個 Service:

ServiceMilestone收斂目標
PermissionMenuL323permission_subcate × permission_item × user_permission2 × org_permission 4-table JOIN
BackupInfoL325md5(md5(md5('SQL'))) 7z 備份檔路徑 glob
FormHelperL319draw_option / draw_checkbox_2col 等表單元件
FormDataLoaderL348每張 form 開頭重複的 patient / form-row / 欄位展開

抽 Service 後的問題是:怎麼防止下個 Controller 不用 Service、又 inline 寫一次?


AppSystemFormServiceConventionTest 的設計

完整的 test class 開頭:

<?php

namespace Tests\Feature;

use Tests\TestCase;

/**
 * L350 — Guardrail tests for module_a form-level services.
 *
 * Phase C established four `App\Services\AppSystemForm\*` services
 * (FormHelper / PermissionMenu / BackupInfo / FormDataLoader) as the
 * ONLY path for module_a controllers to load:
 *   - permission_subcate / permission_item / user_permission2 /
 *     org_permission permission menus
 *   - formnamealias org overrides
 *   - continuity formgroup rows
 *   - 7z backup download links
 *
 * These tests ensure new controllers don't regress by re-introducing
 * inline JOINs / globs that bypass the services. Grows automatically as
 * more Controllers are ported.
 */
class AppSystemFormServiceConventionTest extends TestCase
{
    private const CONTROLLERS_DIR = __DIR__ . '/../../app/Http/Controllers';
    private const VIEWS_DIR       = __DIR__ . '/../../resources/views/module_a';

兩個 const 指向掃描目標:Controller 目錄、Blade view 目錄。


7 個 Guardrail 規則

規則 1:禁止 inline permission_subcate JOIN

public function test_no_inline_permission_subcate_join(): void
{
    $violators = $this->grepControllers(
        "#->table\\(['\"]permission_subcate|"
        . "DB::connection\\(['\"]shared['\"]\\)->.*permission_subcate#"
    );

    $this->assertEmpty(
        $violators,
        "Inline permission_subcate JOIN detected — must use \n"
        . "App\\Services\\AppSystemForm\\PermissionMenu::fetch() instead:\n"
        . implode("\n", $violators)
    );
}

兩個 regex 同時擋:

  • ->table('permission_subcate' — Eloquent / Query Builder 方式
  • DB::connection('shared')->...permission_subcate — Raw 跨 DB 方式

錯誤訊息直接告訴你該用哪個 service:「must use App\Services\AppSystemForm\PermissionMenu::fetch()」。

規則 2:禁止 7z 備份檔 inline glob

public function test_no_inline_backup_file_glob(): void
{
    $violators = $this->grepControllers("#md5\\(md5\\(md5\\('SQL'\\)\\)\\)#");

    $this->assertEmpty(
        $violators,
        "Inline 7z backup-path glob detected — must use \n"
        . "App\\Services\\AppSystemForm\\BackupInfo::latest() instead:\n"
        . implode("\n", $violators)
    );
}

md5(md5(md5('SQL'))) 這個三重 md5 是 Legacy 系統用來混淆檔案路徑的(fs-only obfuscation,不是真的安全)。整個專案中沒有第二個用途。用它當 signature 比掃 glob( 精確得多

規則 3:禁止 Legacy DB class 在 Controller

public function test_no_legacy_db_class_instantiation(): void
{
    // Match `new DB(` / `new DB2(` / `new DBPDO(` / `new DBPDO_read(` / `new xDB(`
    // AND the parenthesis-less PHP shorthand `new DB;` / `new DB2;` / etc.
    $violators = $this->grepControllers(
        "#(?<![a-zA-Z_])new\\s+(DB|DB2|DBPDO|DBPDO_read|xDB)\\s*[(;]#"
    );

    $this->assertEmpty(
        $violators,
        "Legacy DB class instantiation detected in a Controller — must use \n"
        . "Laravel's `DB::connection(...)` facade or Eloquent models instead:\n"
        . implode("\n", $violators)
    );
}

關鍵 regex 設計:

  • (?<![a-zA-Z_]) — negative lookbehind,避免命中 MyDB 之類的
  • new\s+(DB|DB2|DBPDO|DBPDO_read|xDB) — 5 個 Legacy DB class
  • \s*[(;] — 同時擋 new DB( 和 PHP4 風格的 new DB;

規則 4:禁止 Legacy DB class 在 Blade View

public function test_no_legacy_db_or_permission_query_in_blade_views(): void
{
    $violators = $this->grepBladeViews(
        "#(?<![a-zA-Z_])(new\\s+(DB|DB2|DBPDO|DBPDO_read|xDB)\\s*[(;]"
        . "|->table\\(['\"]permission_subcate)#"
    );

    // Filter out the grandfather-listed blades.
    $violators = array_values(array_filter($violators, function ($v) {
        $rel = strtok($v, ':');
        $rel = str_replace('\\', '/', (string) $rel);
        return !in_array($rel, self::LEGACY_NEW_DB_BLADE_ALLOWLIST, true);
    }));

    $this->assertEmpty($violators, "...");
}

跟規則 3 同樣的 regex,但掃 Blade 檔案。差別在多了一個 Allowlist 機制——L354 引入時,已有 13 個 Blade 含這些違規,無法一次清。

private const LEGACY_NEW_DB_BLADE_ALLOWLIST = [];

LEGACY_NEW_DB_BLADE_ALLOWLIST 從 13 個項目開始,L367 milestone 清空為空 array。

規則 5:禁止 inline formgroup continuity 查詢

public function test_no_inline_formgroup_continuity_query(): void
{
    $violators = $this->grepControllers(
        "#->table\\(['\"]formgroup['\"]\\)->where\\(['\"]group['\"]#"
    );

    $this->assertEmpty(
        $violators,
        "Inline `formgroup WHERE group=X` query detected — must use \n"
        . "App\\Services\\AppSystemForm\\PermissionMenu::continuityGroups() instead:\n"
        . implode("\n", $violators)
    );
}

formgroup 表存「連續性表單」清單(form A → form B → form C 的順序)。19 個 Controller 都查它。L327 加 PermissionMenu::continuityGroups($group) 集中——這條 guardrail 確保新 Controller 不會繞過。

規則 6:禁止 Legacy global helpers 在 flag=true 模組(L441)

public function test_no_legacy_global_helpers_in_flag_true_blades(): void
{
    $flags  = config('migration.modules', []);
    $errors = [];
    $pattern = "#(?<![\$a-zA-Z_])"
             . "(getWorkingStaff(_cg)?|getSystemSetting|getEmpName)"
             . "\\s*\\(#";

    foreach ($flags as $mod => $enabled) {
        if (!$enabled) {
            continue;
        }
        // 掃這個 module 目錄下所有 .blade.php
        // ...
    }
}

這條規則更精細——只掃 flag=true 的模組(已切到 Laravel 接管的)。

原因:getWorkingStaff() / getSystemSetting() / getEmpName() 這些 global helper 住在 Legacy 的 module_a/class/func/staff_helper.php只在 Legacy include chain 啟動時才存在。一旦 Blade 被 Laravel render(flag=true),這些函式就 undefined。

flag=false 的模組由 Legacy serve、helper 在 scope 內,可以暫時保留。Guardrail 只在「真的會壞掉」的時候攔。

規則 7:flag=true 模組不准有 scaffold placeholder

private const SCAFFOLD_PHRASES = [
    '實際表單邏輯待後續',
    '實際內容尚未遷移',
    '尚未遷移',
    '已遷移至 Laravel。請透過正確的',
    '已由 Laravel 接管',
    'scaffold —',
];

public function test_flag_true_modules_have_no_scaffold_placeholder_in_blades(): void
{
    $flags = config('migration.modules', []);
    $errors = [];

    foreach ($flags as $mod => $enabled) {
        if (!$enabled) continue;
        // ...
        foreach (glob($dir . '/*.blade.php') ?: [] as $file) {
            $body = file_get_contents($file) ?: '';
            // Dead-code marker → allow placeholder-less landing
            if (str_contains($body, 'Dead-code landing page')) continue;

            foreach (self::SCAFFOLD_PHRASES as $phrase) {
                if (str_contains($body, $phrase)) {
                    $errors[] = "{$rel} — contains '{$phrase}'";
                    break;
                }
            }
        }
    }

    $this->assertEmpty($errors, "...");
}

這條規則是 L316 止血名單事件的後遺症處理。當時 43 個模組標 flag=true 但實為 scaffold(空白「已遷移至 Laravel」頁面),使用者會看到空白頁。

L316 把那 43 個 flag 改回 false,後續真實移植再翻 true。但怎麼防止下次又發生?這條規則:

  • flag=true → blade 必須有實際內容
  • 例外:blade 開頭有 Dead-code landing page 註解(formview dispatcher 永遠不 render 的 blade)

兩條 milestone(L340 / L346)建立了 dead-code marker 慣例。


L350 第一次跑就抓到 L336 遺留

L350 milestone 是這樣記錄的:

L350 AppSystemForm service 慣例 guardrail test + mod_care_c 收斂(2026-04-24):新增 AppSystemFormServiceConventionTest 3 個 assertion guardrail——禁未來 Controller 繞過 PermissionMenu / BackupInfo 用 inline ->table('permission_subcate') / md5(md5(md5('SQL'))) / ->table('formgroup')->where('group'第一跑就抓到 ModuleDayCareController 在 L336 留的 inline formgroup 查詢(當時為了取 formLink 給 service_type_flag!=6 分支用);PermissionMenu::continuityGroups() 回傳值加上 formLink 欄位(原本只有 name / groupID / firstFormID),mod_care_c Controller 改用 service 呼叫、移除用不到的 DB facade import。1386 tests 全綠(+3 新 guardrail test)。

時序是這樣的:

L327  PermissionMenu::continuityGroups() 加上
       └─ 回傳 ['name', 'groupID', 'firstFormID']

L336  mod_care_c formlist 真實移植
       └─ 大部分用 PermissionMenu::continuityGroups()
       └─ 但 service_type_flag!=6 分支需要 formLink
       └─ continuityGroups() 沒回傳 formLink
       └─ 只好 inline 再查一次 formgroup 表(技術債埋下)

L350  AppSystemFormServiceConventionTest 引入
       └─ test_no_inline_formgroup_continuity_query
       └─ 第一次跑就抓到 L336 的 inline 查詢

L350 修復步驟:
       1. continuityGroups() 補 formLink 欄位(4 個欄位)
       2. ModuleDayCareController 移除 inline 查詢
       3. 1386 tests 全綠

這是 guardrail 的最佳示範——它沒抓到「未來的回退」,反而抓到了**「過去的妥協」**。

技術債就像是這樣的:當時做不到完美、留下一個 inline 查詢、想說以後再修。沒有 guardrail 就會永遠拖著。


LEGACY_NEW_DB_BLADE_ALLOWLIST 機制

L354 milestone 擴充 guardrail 到 Blade view:

L354 Blade guardrail + LEGACY_NEW_DB_BLADE_ALLOWLIST(2026-04-22)AppSystemFormServiceConventionTest 擴充 Blade scan——grep resources/views/module_a/**/*.blade.phpnew DB[(;] / new DB2[(;] / new DBPDO[(;] / new DBPDO_read[(;] / ->table('permission_subcate。一次抓到 13 個歷史殘留 blade,加入 LEGACY_NEW_DB_BLADE_ALLOWLIST grandfather list。

設計選擇:抓到的不是直接禁止,是 grandfather list

為什麼?因為一次砍 13 個 blade 太大。實務上:

  • Allowlist 列出已知違規 → CI 不會壞
  • 後續每個 milestone 砍掉 1-2 個 → 從 allowlist 移除
  • L367 移除最後一個 → allowlist 變空 array

整個機制設計在 const:

/**
 * L367: allow-list fully cleared. All 13 original grandfather-listed
 * blades have been refactored (L355–L367) to move `new DB;` /
 * `new DB2;` / `new DBPDO;` / `new DBPDO_read;` out of the view and
 * into their Controller's data-loader. The guardrail below now fails
 * hard on ANY blade that reintroduces one of these patterns — no
 * exceptions.
 */
private const LEGACY_NEW_DB_BLADE_ALLOWLIST = [];

註解保留歷史脈絡——後人看到這個 const 知道它的故事。


13 個 Blade 的清零路徑(L355-L367)

Milestone處理對象副產品(修到的 bug)
L355mod_admin_a/form4_1
L356mod_admin_a/form4
L357batch/form_emp_28$Position_r copy-paste bug
L358mod_admin_a/form3name= U+FF1D 全形等號 bug
L359batch/form10替代整個 Form10Class
L360batch/form_employee_training修主任 block duplicate
L361batch/form9
L362batch/form_resplist3
L363batch/form_doctorCheckupList
L364mod_admin_a/form2a
L365mod_admin_a/form1新增 POST /formview 儲存端點(原本 Legacy POST 在 L313 flag 啟用後因 302 redirect + new DB2 未 autoload 實質上失效)
L366batch/form4chart改用 GroupBridge::group()->getProductForApply
L367mod_admin_a/form2 (1290 行)清空 LEGACY_NEW_DB_BLADE_ALLOWLIST

13 步、13 個 blade,每個都是「把 inline DB 邏輯移進 Controller 的 data-loader private method、blade 只 consume compact() 傳回的 array」。

L367 milestone 的描述

L367 最終把 LEGACY_NEW_DB_BLADE_ALLOWLIST 清空 [] — 任何 blade 重新引入 new DB[(;] 系列直接失敗。1390 Laravel tests 全綠,PHPStan baseline 451 維持不動。module_a/index.php 已接入 MigrationFlags — migrated module 自動 302 轉向 /lv.php?/api/module_a/{mod}/{func}。新 API/新功能一律用 Laravel 開發。

清零的時刻定義了一個分界點:從這之後,Blade 永遠不能再 instance Legacy DB class


副產品:清零過程中順便修的 bug

Refactor 不只是搬程式碼。L355-L367 的 milestone 紀錄揭露了三個原本沒人注意的 bug:

Bug 1:$Position_r Copy-paste(L357)

batch/form_emp_28 blade 的 inline DB 查詢中,有個變數 copy-paste 沒改。Refactor 時把整段移進 Controller,讀 code 時看出問題,順手修掉。

Bug 2:U+FF1D 全形等號(L358)

mod_admin_a/form3 中有:

$row->name='something'

注意中間是全形等號 =(U+FF1D),不是半形 =。PHP 把它當成 syntax error 之外的奇怪情況——正常情況下會炸,但因為這段 code 在某個分支永遠不會執行,沒人注意。

Refactor 移到 Controller 時看出來了。

Bug 3:Legacy POST 在 flag=true 後實質失效(L365)

最微妙的一個。

時序:

L313 mod_admin_a flag=true
     └─ Laravel 接管 GET /formview
     └─ 但 POST /formview 沒處理(依然指 Legacy)

之後  使用者送出 form1
     └─ 表單 action 指 Legacy POST endpoint
     └─ Laravel 攔截到 → 302 redirect 到 Laravel GET(沒 POST 路由)
     └─ 或 Legacy code 跑到 new DB2 (autoload 失敗)
     └─ 實質上:使用者按了儲存 → 沒任何反應

這個 bug 從 L313 開始就壞了,沒人發現(因為每個模組畢業初期使用流量不大)。L365 抽 form1 時才注意到「等等,這個表單怎麼儲存的」。

修復:新增 Laravel 的 POST /formview 端點。ModuleAdminA 的 routes 從 3 條增加到 4 條。


給 Claude Code 的價值

1. 錯誤訊息為 AI 設計

每個 guardrail test 的失敗訊息都寫成「這樣不行,請改用 X」格式:

Inline permission_subcate JOIN detected — must use
App\Services\AppSystemForm\PermissionMenu::fetch() instead:
{violator file:line}

當 Claude Code 跑 test 失敗,看到這個訊息,它知道下一步該做什麼。不是「test 失敗了」這種抽象訊息,是「請改用 Y 函式」這種可執行指令。

2. Allowlist 機制讓 AI 漸進處理

LEGACY_NEW_DB_BLADE_ALLOWLIST 從 13 → 0 是 13 個 PR、13 個 milestone。如果一次要求 Claude Code 改 13 個 blade,會超出 context。

Allowlist 讓每次只處理 1 個——把它從清單拿掉、改 1 個 blade、commit。Claude Code 可以一個一個來,每次都驗證綠燈。

3. Test 是給 AI 的「規則 single source of truth」

寫在 CLAUDE.md 裡的規則,Claude Code 不一定遵守。寫在 test 裡,違反了會直接失敗

當你跟 Claude Code 說「重構這個 Controller」,它跑 test、test 失敗、它讀錯誤訊息、它知道要改用 service。規則自動傳遞,不用每次叮嚀


給其他人的借鑑

如果你也在做 Legacy → 新框架的 migration,這個 guardrail pattern 可以複製:

Step 1:先抽 Service / Helper

不要一開始就寫 guardrail。先把重複的 inline 邏輯抽成 service,讓「正確做法」存在。沒有 service 的話 guardrail 只能擋、無法引導。

Step 2:寫第一個 guardrail,加 grandfather list

第一次跑會抓到大量歷史違規。全列進 allowlist——不要為了 CI 綠燈而跳過。Allowlist 的存在本身就是文件,告訴未來的人「這些是已知債,待清」。

Step 3:每次清一個

不要一次清完。每個 milestone 處理 1-2 個——讓 PR 可以 review、可以 revert。

Step 4:清零的那個 milestone 寫進歷史

L367 在 const 上方留下完整註解:「all 13 original grandfather-listed blades have been refactored」。這個註解就是記憶——後人看到不會以為「啊 allowlist 是空的,可以隨便加」。

Step 5:擴充 guardrail 涵蓋新模式

當你抽出新 service(例如 L348 FormDataLoader),就加新 guardrail(例如 L441 禁止 global helper)。guardrail 跟著 service 演化


結語

這個 guardrail test 的價值不在 309 行 PHP——在於把架構規則寫成可執行的程式碼

CLAUDE.md / CONTRIBUTING.md / Code review 都會被忽略。test 不會。

L350 第一次跑抓到 L336 遺留——證明這個 pattern 連「過去的技術債」都能挖出來。L367 清零、allowlist 變空 array——證明這個 pattern 能漸進處理大型 refactor。

從 13 → 0,13 個 milestone、13 個 PR、零回退。這是 guardrail 的故事。

如果你的專案也有「規則大家都知道但沒人遵守」的問題,先寫一個最小的 PHPUnit guardrail,抓你最痛的那個 anti-pattern。第一次跑就會驚訝——你以為早就清掉的舊 pattern,其實還活著。