漸進式 Migration 最危險的時刻不是「還沒做」,是「自以為做完了」。
把功能旗標翻成 true、CI 綠燈、Code review 通過——但使用者一切過去看到空白頁。
這篇拆解一個真實的止血案例:78 個模組的 migration flag、43 個假完成、一次回滾、再逐個真實畢業。L316 milestone 是分水嶺,從那之後每個模組翻 true 都要附上 milestone 編號,flag 不再是「我覺得做完了」,而是「使用者真的看得到實際內容」。
TL;DR
| 項目 | 內容 |
|---|---|
| Migration 範圍 | 78 個 module_a 模組(Legacy PHP → Laravel 11 Blade) |
| 災難 | L313 系列把所有 78 個模組 flag=true,但 43 個實為「已遷移至 Laravel」空白 scaffold |
| 止血 | L316 一次把 43 個 flag 改回 false |
| 重新前進 | L317 起逐個模組畢業,每個都附 milestone 編號 |
| 目前進度 | ~37 個模組 flag=true(真完成)、~41 個 flag=false(待真實移植) |
| 配套機制 | test 雙態切換、scaffold 偵測 guardrail(L350)、dead-code marker 慣例 |
背景:Laravel 11 嵌入式架構
module_a 是 Legacy PHP 系統的核心模組,包含 78 個子模組(registration / category / employer / management 等),總共數千個 PHP 檔。
Migration 不是 rewrite,是 嵌入式架構:
AppSystem/
├── module_a/ ← Legacy PHP,依然處理大部分 routing
│ ├── index.php ← 入口;查 migration flag 決定路由
│ ├── module/
│ │ ├── registration/ ← 35 個已完成 flag=true 的模組之一
│ │ ├── employer/ ← 已完成
│ │ └── ... (78 個子模組)
│ └── ...
└── laravel/ ← Laravel 11 嵌入
├── app/
│ ├── Http/Controllers/ ← 103 個 Controller
│ └── Services/AppSystemForm/ ← 4 個共用 service
├── resources/views/module_a/ ← Blade views
└── config/migration.php ← Feature flag config
module_a/index.php 查 config/migration.php:
- flag=true → 302 redirect 到
/lv.php?/api/module_a/{mod}/{func}(Laravel) - flag=false → 走原本的
module_a/module/{mod}/{func}.php(Legacy)
漸進切換的關鍵就在這個 flag 表。
L313 系列:一口氣標 78 個 true
Phase B 完成 Laravel 嵌入後,Phase C 進入「批次鋪 Blade 骨架」階段。L313 系列的做法:
- 為每個模組建立 Blade view 檔(檔名對應 Legacy)
- 寫個 placeholder content:「已遷移至 Laravel。請透過正確的 URL 參數存取」
- 建立 Controller class 和對應路由
- flag 翻 true,讓
module_a/index.phpredirect 到 Laravel
每個 L313 sub-milestone 看起來都像這樣:
L313 mod_care_plan_c 完成(7 legacy PHP → 7 Blade views + flag 啟用) L313 mod_admin_a 完成(8 legacy PHP → 7 Blade views + flag 啟用) L313 …
config/migration.php 的註解一字排開:
'registration' => true, // L310 pilot rewrite (5 files → Laravel CRUD)
'category' => true, // L311 category module (7 files + 2 AJAX → Laravel CRUD)
'release' => true, // L311 release module (5 files → Laravel CRUD + S3 + email)
'censor' => true, // L311 censor module (5 files → Laravel views + MOHW API)
'employer' => true, // L312 employer module (39 files → 38 Blade views + controller)
// ... 共 78 行
每個都標註「N files → M Blade views + controller」,看起來井井有條。
問題:「Blade views」≠「Blade views with content」。
L316 災難:43 個 flag=true 模組是空白頁
L313 系列做完,使用者開始試用——一切到某些模組看到的是:
已遷移至 Laravel。
請透過正確的 URL 參數存取。
不是 error,不是 404,就是這段佔位文字。功能不見了。
L316 milestone 的記錄(直接引用):
L316 scaffold-only flag 止血(2026-04-22):先前 43 個模組標示「flag 啟用」實為 Blade 骨架佔位(views 全是「已遷移至 Laravel」的空白頁),若 redirect 生效使用者會看到空白頁。已把這 43 個 flag 改為
false,對應 test 從test_*_migration_flag_enabled改為test_*_migration_flag_disabled,使用者回到能用的 legacy。真正有實作內容的 35 個模組 flag 保持true。Phase C L316 之後的真實移植每完成一個就把對應 flag 翻回true。
43 個模組(占 78 個總模組的 55%)。包含一些大模組:
| 模組編號 | Legacy 檔案數 |
|---|---|
mod_management | 307 |
mod_form_X | 246 |
mod_social | 137 |
mod_billing | 134 |
mod_management_2 | 129 |
mod_case | 117 |
mod_billing_2 | 88 |
這些模組「flag=true 但只有 scaffold」實際上意味著——核心功能對使用者消失了一陣子。
止血手法:兩個層次的回滾
層次 1:config 翻回 false
config/migration.php 中 43 個項目從 true 改成 false:
// Before (L313 後)
'billing' => true, // L313 billing module (134 files → 134 Blade views + controller)
// After (L316 止血)
'billing' => false, // L313 billing module (134 files → 134 Blade views + controller)
註解保留「L313」歷史,但 flag 改 false。不刪註解的原因:未來這個模組真實畢業時要回填新的 milestone。
層次 2:Test 雙態切換
每個 Controller 都有對應的 test class。L313 寫的 test 是這樣的格式:
// L313 寫的版本
public function test_billing_migration_flag_enabled(): void
{
config(['migration.modules.billing' => true]);
// ... assertions ...
$this->assertTrue(config('migration.modules.billing'));
}
L316 止血時,43 個 test 改名 + 改邏輯:
// L316 止血版
public function test_billing_migration_flag_disabled(): void
{
config(['migration.modules.billing' => false]);
// ... assertions ...
$this->assertFalse(config('migration.modules.billing'));
}
為什麼改 test 名而不是改 assertion?
Test 名 = 對「現狀的描述」。test_billing_migration_flag_enabled 在說「這個模組已啟用」,當它失敗時開發者第一個念頭是「為什麼啟用的會失敗」。
但實際情況是「這個模組目前是 disabled(因為 scaffold-only)」。Test 名要反映真相——test_billing_migration_flag_disabled + assertFalse 才是準確描述。
L316 沒回滾:35 個真完成的模組
不是所有模組都被止血。35 個模組確實寫了實際 Blade content,這些保留 flag=true:
registration / category / release / censor / employer / mod_hr_main /
batch / mod_hr_c / report / maintenance / mod_special_b /
mod_case_b / mod_case_a / mod_admin_g / mod_hr_emp_a /
mod_care_plan_c / mod_admin_a / mod_meal_a / mod_care_plan_a / mod_care_plan_b /
mod_med / customer / mod_psy / registration_hs / mod_hr_d /
mod_hr_b / mod_admin_f / mod_meal_main / mod_acct /
mod_hr_a / mod_nutri_b / mod_nutri_a / mod_postnatal_b / mod_admin_c /
mod_support
特徵是這些模組大多是「功能單純的管理介面」(人員、班表、體檢等),不是「使用者每天必用的核心表單」(住民照護紀錄、申報之類)。
這意味著 L313 階段做事方式:先撿軟柿子。複雜模組(management 307 個檔、mod_form_a 246 個檔)寫不完就先 scaffold——而 scaffold 的決定當下沒考慮到 flag=true 對使用者的衝擊。
翻回 true 的模板:L317 mod_rehab_main 第一個畢業
L316 之後,怎麼安全地把模組翻回 true?L317 milestone 設立了模板:
L317 mod_rehab_main formlist 真實移植(2026-04-22):ModuleRehabController::formlist() 實作 formgroup(group=2) 連續性表單查詢 + cateID=73 權限圖示選單 + formnamealias 組織別名覆寫。跨 DB 查詢
vhost69124-1.formorderremind改用DB::selectOnewithconfig('database.connections.shared.database')。formlist.blade.php 從 scaffold 替換為實際渲染。form1 / form14 仍為 scaffold,flag 持續為false,待 form1 + form14 實作後再翻回 true。
關鍵原則:整個模組所有 blade 真實移植完成,才翻 true。
L317 / L318 / L319 / L320 處理 mod_rehab_main 的 4 張 blade(formlist / form1 / form14 + formview dispatcher),每個 milestone 一張。最後 L321 是「翻 true」儀式:
L321 mod_rehab_main flag 翻 true(2026-04-22):mod_rehab_main 模組 4 張 blade(formlist / form1 / form14 + formview dispatcher)全部真實移植完成,
config/migration.modules.mod_rehab_main從 false 改回 true,ModuleRehabControllerTest::test_mod_rehab_main_migration_flag_disabled改回test_mod_rehab_main_migration_flag_enabled+assertFalse改回assertTrue。L316 止血後第一個完成整個模組真實移植的案例。 剩 42 個 scaffold-only 模組依同樣模式逐個處理。
從此每個模組畢業都這個 pattern:
- 一個 milestone 處理一張 blade(從 scaffold 改為實際渲染)
- 期間 flag 持續 false(使用者走 Legacy 路徑)
- 所有 blade 都真實移植後,最後一個 milestone 翻 flag
- 同時改 test 名 / assertion
完整模組畢業時間軸(從 milestone 萃取)
| 順序 | Milestone | 模組 | Blade 數 | 備註 |
|---|---|---|---|---|
| 第 1 個 | L321 | mod_rehab_main | 4/4 | L316 止血後首個畢業 |
| 第 6 個 | L504 | dailywork | 30/31 + leftcol dead-code | 跨 30+ 個 milestone(L478-L504) |
| 第 7 個 | L509 | mod_care_b | 10/10 form blade | 含 S3 動態自訂領域 |
| … | … | … | … | … |
dailywork 模組是個極端例子:31 張 blade、30+ 個 milestone(L478-L504)才畢業。每張 blade 一個 milestone:
L478 dailywork 開始 piece-by-piece port - form4 + form6 + form6_1 L479 form_report (血壓報表) + leftcol 標記 Dead-code landing page L480 resplist2_weight + resplist2db L481 dailywork_respedit (vital-signs edit form) — 含 11 LOINC UPSERT L482 dailywork_printvitalsign (月份生命徵象列印) — 單一 BETWEEN query 取代 legacy LIKE on RecordedTime … L503 dailywork_patient_health + printpatient_health (daily/month) — 抽出 translatePatientHealthRow helper L504 dailywork flag 翻 true
整個過程花了 30+ 個 milestone。這就是「真實移植」的真實成本。
配套機制 1:scaffold 偵測 guardrail(L350)
L316 止血是事後補救。怎麼防止下次再發生?
L350 引入 AppSystemFormServiceConventionTest 中的 test_flag_true_modules_have_no_scaffold_placeholder_in_blades:
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;
// 掃這個 module 的所有 blade
// 如果 flag=true 但 blade 含 scaffold phrase → 報錯
}
}
這條 guardrail 自動防止「flag=true 但 blade 還是 placeholder」的災難重演。任何 PR 把 flag 翻 true 但 blade 沒寫好,CI 直接擋下。
配套機制 2:Dead-code Marker 慣例(L340)
但有些 blade 確實是空的——例如 formview.blade.php 是個 dispatcher,永遠不會 render,只是為了通過 view-count test。這種怎麼處理?
L340 milestone 引入 Dead-code landing page marker:
L340 mod_rehab_main formview blade 去 scaffold 標記(2026-04-22):ModuleRehabController dispatcher 實際上永遠不 render
formview.blade.php(empty id 轉 formlist、invalid id 404、valid id 轉 form{id}),所以 blade 內 L316 前的「請透過正確的 URL 參數存取」scaffold 佔位是 dead code。改為註解說明這是 unreachable landing page + 最小 markup(只為了滿足 blade view count test)。
這些 blade 內加上:
{{-- Dead-code landing page: this view is never rendered.
ModuleRehabController dispatches:
- empty id → formlist
- invalid id → 404
- valid id → form{id}
This file exists only to satisfy the blade view count test. --}}
L350 的 guardrail 會檢查 Dead-code landing page 字串:
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; // skip 這個 blade
}
// ... 其他檢查
}
標記 + 自動例外——慣例驅動 guardrail。
L346 milestone 把 marker 套到另外 3 個模組(mod_nutri_a / mod_postnatal_b / mod_admin_c):
L346 mod_nutri_a / mod_postnatal_b / mod_admin_c formview blade 去 scaffold 標記(2026-04-22):這三個 flag=true 模組的
formview.blade.php本來是「已遷移至 Laravel 請透過正確的 URL 參數存取」scaffold 佔位 dead-code landing page。沿用 L340 mod_rehab_main 同模式:改為註解說明 + 最小 markup 只為了 blade view count test。掃描 flag=true 模組 0 個 scaffold placeholder 殘留。
配套機制 3:環境變數覆寫 (Kill-switch)
config/migration.php 有兩個額外開關:
return [
'modules' => array_fill_keys(
array_filter(explode(',', (string) env('UCARE_MIGRATION_MODULES', ''))),
true,
) + [
// ... 78 個模組
],
/*
| 全域開關。false 時 shouldUseLaravel() 永遠回 false(緊急 kill-switch)
*/
'enabled' => filter_var(env('UCARE_MIGRATION_ENABLED', 'true'), FILTER_VALIDATE_BOOLEAN),
];
兩層保險:
1. UCARE_MIGRATION_MODULES(前測)
要試某個模組之前先在 staging 開:
UCARE_MIGRATION_MODULES=billing,case php artisan serve
不需要 commit、不影響其他環境。覆寫機制保證 env 永遠先合併、且全部視為 true。
2. UCARE_MIGRATION_ENABLED(緊急 kill-switch)
正式環境如果發現某個 Laravel 路徑大規模出錯:
UCARE_MIGRATION_ENABLED=false
所有 flag=true 模組瞬間退回 Legacy。不需要 deploy、不需要改 config,只要環境變數重啟。
這是 L306 milestone 設計時就考慮到的——有開關才敢開風險。
給 Claude Code 的價值
1. Feature flag 給 AI 一個明確邊界
當 Claude Code 處理 billing 模組相關 issue,先看 config/migration.modules.billing:
- true → 改 Laravel Controller、改 Blade view
- false → 改 Legacy
module_a/module/billing/*.php
flag 替 AI 解決「該動哪邊」的問題。沒有 flag,AI 看到兩份 code,要猜現在哪份在用、改哪份才有效。
2. Test 雙態設計反映現狀
test_billing_migration_flag_enabled 失敗時,AI 看到 test 名字就懂:「啊,這個模組現在是 enabled 狀態,所以 Laravel 路徑壞了」。
如果 test 名跟現狀脫節(test 寫 enabled 但實際 disabled),AI 會搞混——以為要修 Laravel,但其實該修 Legacy。
Test 名是現狀的 single source of truth。
3. Milestone log 給 AI 完整 context
PHASEC_MILESTONES.md 是 append-only。L316 永遠存在,L317 接著 L316,L321 接著 L317。
當 Claude Code 看到 billing flag 是 false,它可以 grep milestone log 看到 L316 標 false 的原因。它不會以為 false 是 bug,會知道是 deliberate。
給其他人的借鑑
如果你也在做漸進式 Migration,這個 pattern 可以複製:
Step 1:先建 flag 系統,再 migrate
不要直接改 code、希望沒人發現。先建立 config/migration.modules + 路由判斷層。沒有開關不要動工。
Step 2:scaffold 階段的 flag 必須是 false
L313 系列的災難根源是:scaffold 階段把 flag 翻 true。
正確做法應該是:scaffold 階段 flag=false(使用者走 Legacy),等真實 content 完成才翻 true。如果 PHP framework 強制要 flag=true 才能 test Blade,建立 staging-only 的 env 覆寫機制。
Step 3:test 反映現狀,不反映目標
test_X_migration_flag_enabled ↔ test_X_migration_flag_disabled 對等切換。Flag 改、test 也要改。
不要寫成 test_X_should_be_enabled——這是「期望」,不是「現狀」。
Step 4:寫 guardrail 防止再發生
L350 / L354 系列的 PHPUnit guardrail。一次踩坑後就立規矩,後人不會犯同樣錯。
Step 5:Milestone log 是 append-only
PHASEC_MILESTONES.md 不刪舊條目。L316 的記錄在那裡是好事——它告訴未來人「我們也曾犯過這個錯」。
刪歷史 = 注定重複犯。
結語
漸進式 Migration 最珍貴的不是「進度」——是**「進度的真實性」**。
L313 系列把 78 個模組標 true 看起來進度很好;L316 止血把 43 個改回 false 看起來進度倒退。但 L316 之後的進度是真的進度——每個 flag 翻 true 都對應實際使用者體驗的提升。
Flag、Test、Milestone log 三者形成一個 feedback loop:
- Flag 反映現狀
- Test 雙態反映 flag
- Milestone log 記錄歷史
當這個 loop 健康,AI 處理 issue 時可以信任它;當這個 loop 失靈(flag 標 true 但 blade 是空),下游全部失靈。
如果你的 Migration 看起來「很順」、所有 flag 都 true、但使用者一直回報問題——先檢查每個 flag=true 模組的 blade 是不是真的有 content。L316 那 43 個假完成模組的故事,可能正在你的專案發生。