Hybrid Mode:讓 Laravel 與 Legacy PHP 在同一個請求裡共存


把一個 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 提供了三件事:

  1. 逐模組切換:不用一次全切,一個模組驗證好了才翻 true
  2. 即時回滾:某模組 Laravel 出問題,flag 改 false 立刻退回 Legacy,不用 deploy
  3. 緊急 kill-switch:環境變數 UCARE_MIGRATION_ENABLED=false 一次全退回 Legacy

有開關才敢遷移——這是 hybrid 模式能安全推進的前提。


Hybrid 的三種狀態

一個功能在遷移過程中會經歷這幾種狀態:

狀態GET(render)POST(save)JS(ajax)
完全 LegacyLegacyLegacyLegacy
Hybrid(初期)LaravelLegacyLegacy
Hybrid(中期)LaravelLaravelLegacy
完全 LaravelLaravelLaravelLaravel

遷移就是一格一格從 Legacy 變 Laravel,不是一次跳到底。每一步都可獨立驗證、獨立回滾。最危險的是「初期 → 中期」那一步(POST 從 Legacy 切到 Laravel),就是前面那個坑。


Claude Code 在這裡的角色

整個 Phase C 遷移是用 Claude Code 一個 milestone 一個 milestone 推進的——milestone 文件本身就是工作日誌(L301 一路到 L600+)。Claude Code 在 hybrid 遷移中做的事:

  1. 逐模組移植 Blade:把 Legacy PHP 頁面改寫成 Blade,controller 載入資料
  2. 保持 hybrid 接點:知道「render 切 Laravel 但 save 暫留 Legacy」這個慣例,不會擅自把 POST 也改掉(除非該步驟就是要改 POST)
  3. 驗證每一步:每翻一個 flag,跑對應的 controller test 確認沒壞
  4. 記錄到 milestone:每步寫進 PHASEC_MILESTONES.md,下次接手有完整 context

關鍵是把「hybrid 慣例」寫進專案的 CLAUDE.md——讓 Claude Code 知道「這個專案現在是新舊並存、render 和 save 可能走不同框架」,它才不會看到「表單 action 指向 Legacy」就以為是 bug 去「修」。


給其他人的借鑑

如果你也要把 Legacy 系統漸進遷移到新框架,hybrid 模式的關鍵原則:

  1. 入口處用 flag 分流:一個地方決定「這個請求走新還是舊」,最好是 router/前控制器層
  2. render / save / ajax 拆開遷移:不要想「整個功能一次搬」,拆成可獨立切換的小塊
  3. flag 要能即時回滾:出問題改 flag 就退回,不靠 deploy;加一個全域 kill-switch
  4. 小心跨框架的 POST:GET 切了新框架,POST 那條路要確認還通——這是最常見的隱形坑
  5. 把 hybrid 慣例寫進 CLAUDE.md:讓 AI 知道「新舊並存是正常狀態」,不會把過渡接點當 bug 修

結語

大型 Legacy 遷移不是「重寫」,是「換引擎不熄火」——系統要持續服務,新舊框架得在同一個請求週期裡共存一段時間(可能是好幾個月)。

Hybrid 模式(render 新 / save 舊)讓遷移的粒度細到「一個頁面的一半」。配上 feature flag 的逐模組切換和即時回滾,就能在不停機、不大爆炸的前提下,一塊一塊把系統搬到新框架。

代價是要忍受一段「新舊交雜」的過渡期,以及小心那些跨框架的接點(特別是 POST)。但比起「停三個月全部重寫」的豪賭,這是唯一務實的路。