Migration Flag 止血策略:43 個假完成模組的回滾與再前進


漸進式 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.phpconfig/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 系列的做法:

  1. 為每個模組建立 Blade view 檔(檔名對應 Legacy)
  2. 寫個 placeholder content:「已遷移至 Laravel。請透過正確的 URL 參數存取」
  3. 建立 Controller class 和對應路由
  4. flag 翻 true,讓 module_a/index.php redirect 到 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_management307
mod_form_X246
mod_social137
mod_billing134
mod_management_2129
mod_case117
mod_billing_288

這些模組「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::selectOne with config('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 改回 assertTrueL316 止血後第一個完成整個模組真實移植的案例。 剩 42 個 scaffold-only 模組依同樣模式逐個處理。

從此每個模組畢業都這個 pattern:

  1. 一個 milestone 處理一張 blade(從 scaffold 改為實際渲染)
  2. 期間 flag 持續 false(使用者走 Legacy 路徑)
  3. 所有 blade 都真實移植後,最後一個 milestone 翻 flag
  4. 同時改 test 名 / assertion

完整模組畢業時間軸(從 milestone 萃取)

順序Milestone模組Blade 數備註
第 1 個L321mod_rehab_main4/4L316 止血後首個畢業
第 6 個L504dailywork30/31 + leftcol dead-code跨 30+ 個 milestone(L478-L504)
第 7 個L509mod_care_b10/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_enabledtest_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 個假完成模組的故事,可能正在你的專案發生。