把一個 10 年的 Legacy PHP 系統改寫成 Laravel,最不切實際的計畫就是「停下來,全部重寫,再上線」。
真實世界的做法是共存:新框架和舊系統跑在同一個 web root、同一個請求週期裡,一個模組一個模組慢慢搬。這篇記錄一個真實的 hybrid 模式——頁面由 Laravel Blade 渲染,但表單還是 POST 回 Legacy PHP——以及用 feature flag 逐模組遷移時踩過的坑。
共存的基本機制
Laravel 11 嵌在 Legacy 的 web root 裡,入口檔 module_a/index.php 接上一個 feature flag:
// module_a/index.php(簡化)
require 'config/migration.php';
if (MigrationFlags::shouldUseLaravel($mod)) {
// 已遷移的模組 → 302 轉去 Laravel
header("Location: /lv.php?/api/module_a/{$mod}/{$func}");
exit;
}
// 沒遷移的 → 照舊走 Legacy module_a/module/{mod}/{func}.php
關鍵就這一段:入口處查 flag,決定這個請求走 Laravel 還是 Legacy。
- flag = true → 302 轉向
/lv.php?/api/module_a/{mod}/{func}(Laravel Blade 頁面) - flag = false → 走原本的 Legacy PHP
兩套框架共用同一個 web root、同一個資料庫、同一個 session。差別只在「這個模組的這個功能,由誰處理」。
什麼是 Hybrid:render 新、save 舊
最有意思的不是「整個模組切到 Laravel」,而是一個頁面內部的混血。
遷移初期,很多頁面是這樣的(這個專案的 milestone 紀錄稱為 hybrid 模式):
頁面載入(GET)→ Laravel Blade 渲染 ← 新
↓
使用者填表單,按儲存(POST)→ Legacy PHP 處理 ← 舊
具體來說,一張表單的 blade 長這樣:
{{-- Laravel 渲染的表單,但 action 指回 Legacy --}}
<form action="index.php?func=database&action=save" method="post">
{{-- 欄位由 Laravel controller 載入的資料填入 --}}
<input name="Qname" value="{{ $patient->Qname }}">
...
</form>
<script>
// JS 還呼叫 Legacy 的 endpoint
$.get('class/city.php', ...); // 縣市下拉
$.get('class/code_lookup.php', ...); // 代碼查詢
</script>
為什麼這樣搞:因為「渲染」和「儲存」可以分開遷移。先用 Laravel 把畫面接管(享受 Blade 的好處:元件化、測試、乾淨的資料載入),但儲存邏輯太複雜、暫時不動——讓它繼續 POST 回 Legacy 已經驗證過的存檔流程。
這是漸進式遷移的精髓:不是「整個功能搬過去」,是「一個功能拆成 render / save / ajax 幾塊,各自獨立遷移」。
踩過的坑:flag 啟用後,儲存默默失效
這個 hybrid 模式藏著一個陰險的坑。某個 milestone 記錄了它:
某模組 flag 啟用後,legacy POST path 因為
302 redirect + new DB2 未 autoload而失效——使用者按儲存,沒有任何反應。
時序是這樣的:
1. 模組 flag 翻 true → Laravel 接管 GET(頁面正常顯示)
2. 但表單 action 還指向 Legacy 的 index.php?action=save(POST)
3. 使用者按儲存 → POST 打到 Legacy index.php
4. index.php 查 flag = true → 302 轉去 Laravel(但 Laravel 沒有對應的 POST 路由!)
或:Legacy 存檔 code 跑到 new DB2(),但 Laravel 環境沒 autoload 這個 class → fatal
5. 結果:使用者按了儲存,畫面沒反應,資料沒存進去
最陰險的是它不會報錯給使用者——GET 正常、頁面好好的,只有按儲存才默默失敗。而且初期流量不大,沒人馬上發現。
修法:在 Laravel 補上對應的 POST /formview 儲存端點,讓「render 新 / save 也新」。AbManagement 之類的模組 routes 就從 3 條變 4 條(多一條 POST)。
這個坑的教訓:hybrid 模式下,GET 和 POST 走不同框架時,要確認 POST 那條路真的還通。flag 只控制了 GET 的轉址,POST 是另一回事。
Feature Flag:遷移的總開關
整個 hybrid 遷移靠 config/migration.php 的 flag 表控制,哪個模組好了就翻 true。這個 flag 系統本身有完整的故事——包括「43 個模組標 true 但其實是空白 scaffold 頁」的止血事件,那篇有詳述,這裡不重複。
重點是 flag 提供了三件事:
- 逐模組切換:不用一次全切,一個模組驗證好了才翻 true
- 即時回滾:某模組 Laravel 出問題,flag 改 false 立刻退回 Legacy,不用 deploy
- 緊急 kill-switch:環境變數
UCARE_MIGRATION_ENABLED=false一次全退回 Legacy
有開關才敢遷移——這是 hybrid 模式能安全推進的前提。
Hybrid 的三種狀態
一個功能在遷移過程中會經歷這幾種狀態:
| 狀態 | GET(render) | POST(save) | JS(ajax) |
|---|---|---|---|
| 完全 Legacy | Legacy | Legacy | Legacy |
| Hybrid(初期) | Laravel | Legacy | Legacy |
| Hybrid(中期) | Laravel | Laravel | Legacy |
| 完全 Laravel | Laravel | Laravel | Laravel |
遷移就是一格一格從 Legacy 變 Laravel,不是一次跳到底。每一步都可獨立驗證、獨立回滾。最危險的是「初期 → 中期」那一步(POST 從 Legacy 切到 Laravel),就是前面那個坑。
Claude Code 在這裡的角色
整個 Phase C 遷移是用 Claude Code 一個 milestone 一個 milestone 推進的——milestone 文件本身就是工作日誌(L301 一路到 L600+)。Claude Code 在 hybrid 遷移中做的事:
- 逐模組移植 Blade:把 Legacy PHP 頁面改寫成 Blade,controller 載入資料
- 保持 hybrid 接點:知道「render 切 Laravel 但 save 暫留 Legacy」這個慣例,不會擅自把 POST 也改掉(除非該步驟就是要改 POST)
- 驗證每一步:每翻一個 flag,跑對應的 controller test 確認沒壞
- 記錄到 milestone:每步寫進 PHASEC_MILESTONES.md,下次接手有完整 context
關鍵是把「hybrid 慣例」寫進專案的 CLAUDE.md——讓 Claude Code 知道「這個專案現在是新舊並存、render 和 save 可能走不同框架」,它才不會看到「表單 action 指向 Legacy」就以為是 bug 去「修」。
給其他人的借鑑
如果你也要把 Legacy 系統漸進遷移到新框架,hybrid 模式的關鍵原則:
- 入口處用 flag 分流:一個地方決定「這個請求走新還是舊」,最好是 router/前控制器層
- render / save / ajax 拆開遷移:不要想「整個功能一次搬」,拆成可獨立切換的小塊
- flag 要能即時回滾:出問題改 flag 就退回,不靠 deploy;加一個全域 kill-switch
- 小心跨框架的 POST:GET 切了新框架,POST 那條路要確認還通——這是最常見的隱形坑
- 把 hybrid 慣例寫進 CLAUDE.md:讓 AI 知道「新舊並存是正常狀態」,不會把過渡接點當 bug 修
結語
大型 Legacy 遷移不是「重寫」,是「換引擎不熄火」——系統要持續服務,新舊框架得在同一個請求週期裡共存一段時間(可能是好幾個月)。
Hybrid 模式(render 新 / save 舊)讓遷移的粒度細到「一個頁面的一半」。配上 feature flag 的逐模組切換和即時回滾,就能在不停機、不大爆炸的前提下,一塊一塊把系統搬到新框架。
代價是要忍受一段「新舊交雜」的過渡期,以及小心那些跨框架的接點(特別是 POST)。但比起「停三個月全部重寫」的豪賭,這是唯一務實的路。