把 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 |
| Milestone | L350 引入、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:
| Service | Milestone | 收斂目標 |
|---|---|---|
PermissionMenu | L323 | permission_subcate × permission_item × user_permission2 × org_permission 4-table JOIN |
BackupInfo | L325 | md5(md5(md5('SQL'))) 7z 備份檔路徑 glob |
FormHelper | L319 | draw_option / draw_checkbox_2col 等表單元件 |
FormDataLoader | L348 | 每張 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):新增
AppSystemFormServiceConventionTest3 個 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——grepresources/views/module_a/**/*.blade.php找new DB[(;]/new DB2[(;]/new DBPDO[(;]/new DBPDO_read[(;]/->table('permission_subcate。一次抓到 13 個歷史殘留 blade,加入LEGACY_NEW_DB_BLADE_ALLOWLISTgrandfather 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) |
|---|---|---|
| L355 | mod_admin_a/form4_1 | — |
| L356 | mod_admin_a/form4 | — |
| L357 | batch/form_emp_28 | 修 $Position_r copy-paste bug |
| L358 | mod_admin_a/form3 | 修 name= U+FF1D 全形等號 bug |
| L359 | batch/form10 | 替代整個 Form10Class |
| L360 | batch/form_employee_training | 修主任 block duplicate |
| L361 | batch/form9 | — |
| L362 | batch/form_resplist3 | — |
| L363 | batch/form_doctorCheckupList | — |
| L364 | mod_admin_a/form2a | — |
| L365 | mod_admin_a/form1 | 新增 POST /formview 儲存端點(原本 Legacy POST 在 L313 flag 啟用後因 302 redirect + new DB2 未 autoload 實質上失效) |
| L366 | batch/form4chart | 改用 GroupBridge::group()->getProductForApply |
| L367 | mod_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,其實還活著。