PHP 7.4 不能升級怎麼辦?Legacy 專案的漸進式改善策略
這篇文章假設你有維護 Legacy PHP 系統的經驗。如果想了解 AI 如何協助 Legacy PHP 除錯,可以先閱讀: 📖 Claude Code 實戰:用 AI 除錯 Legacy PHP 專案
🎯 前言
「PHP 7.4 都已經 EOL 了,你們什麼時候升級?」
這個問題我被問了好多次。每次我都只能苦笑:我當然知道要升級,但 AppSystem 有個依賴的 PDF 套件,只相容 PHP 7.x,升到 PHP 8 就直接壞掉。替換這個套件的評估成本遠超過升級 PHP 本身的收益。
這是很多 Legacy 系統的現實:版本升級和品質改善是兩件獨立的事。
換個比喻:版本升級像是換引擎,品質改善像是維修保養。就算引擎換不了,保養還是要做。這篇文章分享的是:在 PHP 7.4 的框架下,如何讓 AppSystem 的程式碼品質持續進步。
🔍 為什麼 AppSystem 還在 PHP 7.4
簡單說清楚技術鎖定的原因:
PDF 套件技術鎖定
AppSystem 使用一套商業 PDF 套件處理報表輸出。這套套件在 PHP 8.0 引入的 deprecated 語法變更下會產生大量警告,在 PHP 8.1 之後直接因為 nullable 型別的行為改變而出錯。
升級評估的結果:
- 替換 PDF 套件:估計 2-3 個月工程
- 升級 PHP:預計要修復 500+ 個相容性警告
- 總代價:約等於一個中型功能開發的工時
接受現實之後,問題從「何時升級」變成「在 7.4 的限制下,還能做什麼」。
📊 現狀盤點:導入 PHPStan
改善的第一步是量化現狀。我們導入 PHPStan 做靜態分析:
# 安裝 PHPStan
composer require --dev phpstan/phpstan
# 從最寬鬆的 level 0 開始
./vendor/bin/phpstan analyse src/ --level=0
第一次跑出來的結果
[ERROR] Found 2847 errors
2,847 個錯誤。看起來很嚇人,但先不要慌——大部分是同一類問題重複出現。
phpstan.neon 設定
# phpstan.neon
parameters:
phpVersion: 70400 # PHP 7.4
level: 0
paths:
- src/
# 使用 baseline 機制處理存量錯誤
ignoreErrors: []
Baseline 策略:先把存量問題凍結
PHPStan 的 baseline 功能可以把目前的所有錯誤「記錄下來」,之後只報新的錯誤:
# 產生 baseline(把 2847 個錯誤全部記錄進去)
./vendor/bin/phpstan analyse --generate-baseline phpstan-baseline.neon
# phpstan.neon(加入 baseline)
includes:
- phpstan-baseline.neon
parameters:
level: 0
paths:
- src/
這樣做的效果:
- 今天的 2,847 個問題不再報出來(它們在 baseline 裡)
- 往後任何新增的程式碼,如果引入同類問題,會立刻被抓到
- 每次修復舊問題並從 baseline 中移除,錯誤數就會減少
讓 Claude 幫你分類 baseline 中的問題:
我:
以下是 PHPStan baseline 的內容(節錄),請幫我:
1. 按錯誤類型分類統計
2. 哪些類型最多?
3. 哪些類型最容易批次修復?
[貼上 baseline 內容]
✅ 改善一:逐步引入 Type Hints
PHP 7.4 支援的型別功能相當完整,足以寫出類型安全的程式碼:
PHP 7.4 支援的型別
// ✅ PHP 7.4 支援
function process(int $id, string $name): array { }
function findOrNull(int $id): ?User { } // nullable
class CaseRecord {
public int $id; // typed property(7.4 新增)
public string $name;
public ?string $note = null; // nullable typed property
}
// ❌ PHP 7.4 不支援(需要 PHP 8.0+)
function handle(int|string $value): void { } // union type
function get(): never { } // never type
Before / After:加上 Type Hints
改善前(典型的 Legacy 寫法):
// ❌ 改善前:沒有任何型別資訊
class CaseRepository {
private $db;
public function __construct($db) {
$this->db = $db;
}
public function findById($id) {
$stmt = $this->db->prepare('SELECT * FROM cases WHERE id = ?');
$stmt->execute([$id]);
return $stmt->fetch();
}
public function save($data) {
// $data 是什麼?沒人知道
// 回傳什麼?也沒人知道
}
}
改善後:
// ✅ 改善後:PHP 7.4 型別標注
class CaseRepository {
private PDO $db;
public function __construct(PDO $db) {
$this->db = $db;
}
public function findById(int $id): ?array {
$stmt = $this->db->prepare('SELECT * FROM cases WHERE id = :id');
$stmt->execute([':id' => $id]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return $result ?: null;
}
public function save(array $data): bool {
// 回傳 bool 表示成功/失敗
}
}
讓 Claude 批次補全 Type Hints
我:
以下是 CaseService 類別,請幫我:
1. 補上所有方法的 parameter type hints 和 return type hints
2. 使用 PHP 7.4 支援的型別(不要用 union type 或 PHP 8 的語法)
3. 不改變邏輯,只加型別標注
4. 如果有不確定的地方,用 mixed 或加上 TODO 注解
[貼上程式碼]
✅ 改善二:PSR 標準選擇性採用
PSR 標準有很多,不用全部採用。按優先順序:
優先級 1:PSR-4 Autoloading
這是「最划算」的改善。一次設定好,之後所有新增的 class 都能自動載入,不需要手動 require:
// composer.json
{
"autoload": {
"psr-4": {
"AppSystem\\": "src/"
}
}
}
composer dump-autoload
優先級 2:PSR-12 程式碼風格(自動化)
PSR-12 的格式規範可以用 PHP-CS-Fixer 自動套用,不需要手動逐行修改:
composer require --dev friendsofphp/php-cs-fixer
// .php-cs-fixer.dist.php
<?php
$finder = PhpCsFixer\Finder::create()
->in(__DIR__ . '/src');
return (new PhpCsFixer\Config())
->setRules([
'@PSR12' => true,
])
->setFinder($finder);
# 檢查(不修改)
./vendor/bin/php-cs-fixer fix --dry-run --diff
# 自動修復
./vendor/bin/php-cs-fixer fix
暫時跳過
- PSR-7(HTTP Message Interface):需要重構整個 HTTP 層,代價太高
- PSR-15(HTTP Handlers):同上
- PSR-3(Logger Interface):值得考慮,但不急
✅ 改善三:AI 輔助的安全重構
「安全重構」的定義:行為完全不變,只改善程式碼結構。
讓 Claude 提取重複邏輯
Legacy 程式碼最常見的問題是「複製貼上的重複邏輯」。讓 Claude 識別並提取:
我:
以下是三個函式,它們的核心邏輯幾乎一樣(都是查詢資料庫並回傳特定格式),
但細節略有不同。請幫我:
1. 識別重複的部分
2. 設計一個通用函式,讓三個函式都能呼叫
3. 確保重構後的行為和重構前完全相同
4. 使用 PHP 7.4 語法
[貼上三個函式]
讓 Claude 拆分大函式
Legacy 程式碼常見「一個函式做很多事」的問題:
我:
以下是一個 200 行的函式,它做了很多事(驗證輸入、查詢資料庫、計算、更新、寄送 email)。
請幫我把它拆分成幾個小函式。要求:
1. 每個小函式只做一件事
2. 主函式保留,改成呼叫這些小函式
3. 行為完全不變
4. 不要引入新的依賴
[貼上函式]
重要原則:一次只重構一個函式,確認後再進行下一個。不要一次重構整個 class。
✅ 改善四:防腐層(Anti-Corruption Layer)模式
這是針對 Legacy 程式碼最有效的架構改善模式之一。
概念
在新程式碼和舊程式碼之間建立一層「防腐層」(Wrapper):
新程式碼 → 防腐層(乾淨的介面) → 舊的 Legacy 邏輯
新程式碼只依賴防腐層的乾淨介面,不直接接觸舊邏輯。當舊邏輯逐漸被重寫,只需要更新防腐層的內部實作,新程式碼不需要改動。
實作範例
舊的 Legacy 程式碼(全域函式、SQL 混在一起):
// ❌ 舊的 Legacy 做法(全域函式)
function get_case_data($case_id, $dbno) {
global $db_connections;
$conn = $db_connections[$dbno];
$sql = "SELECT * FROM cases WHERE id = " . intval($case_id);
$result = mysql_query($sql, $conn);
return mysql_fetch_assoc($result);
}
防腐層(乾淨的介面,內部先 delegate 給舊函式):
// ✅ 防腐層:先 delegate,留下替換路徑
class CaseRepository {
private PDO $db;
public function __construct(PDO $db) {
$this->db = $db;
}
public function findById(int $caseId): ?array {
// TODO: 這裡目前 delegate 給 Legacy 函式
// 替換計畫:直接用 PDO 查詢取代
$result = get_case_data($caseId, $this->getCurrentDbNo());
return $result ?: null;
}
// ... 其他方法
}
新程式碼只使用 CaseRepository,不直接呼叫 get_case_data()。
當 get_case_data() 被重寫,只需要更新 CaseRepository::findById() 的內部實作。
🛠️ 技術債管理
童子軍原則
離開時讓營地比你到達時更乾淨。
每次修改某個函式,順便做一件小改善:
- 加上一個 type hint
- 把一個
mysql_query換成 PDO - 把一個複製貼上的邏輯提取為函式
不要試圖一次解決所有問題,但也不要讓技術債越積越多。
CLAUDE.md 「禁止清單」
在 CLAUDE.md 中建立禁止清單,讓新程式碼不再引入舊問題:
## 程式碼規範
### 禁止使用(新程式碼)
- `mysql_query()`、`mysql_fetch_assoc()`(使用 PDO)
- 直接字串拼接 SQL(使用 PDO prepared statements)
- `echo $_GET[]`、`echo $_POST[]` 未 escape(使用 `h()` 函式)
- `global $variable`(改用依賴注入)
### 必須包含(新程式碼)
- 所有函式必須有 PHP 7.4 型別標注
- 所有 SQL 查詢使用 PDO
- 所有輸出到 HTML 的值使用 `h()`
docs/TECH_DEBT.md
讓 Claude 幫你建立技術債追蹤文件:
我:
根據剛才的 PHPStan 分析結果,請幫我建立一份技術債文件 docs/TECH_DEBT.md,
格式包含:
1. 技術債分類(型別問題、SQL 直接拼接、未 escape 輸出等)
2. 每類問題的數量估計
3. 建議的修復優先順序
4. 可以批次處理的 vs 需要個別處理的
💡 與 Claude Code 協作重構的技巧
技巧 1:一次一個函式
❌ 低效
「請幫我重構整個 CaseService 類別」
✅ 高效
「請幫我重構這個 findByDateRange() 函式,
加上 type hints,並把 SQL 改用 PDO prepared statement。
其他函式先不動。」
技巧 2:要求 Claude 說明改了什麼
重構完成後,請用條列式說明:
1. 改了哪些地方
2. 為什麼這樣改
3. 有沒有改變行為(如果有,在哪裡)
這樣可以快速確認重構是否安全。
技巧 3:讓 Claude 生成 PHPUnit 測試骨架
Legacy 程式碼通常沒有測試。每次重構前,先讓 Claude 產生測試骨架:
我:
在重構 findByDateRange() 之前,請先幫我產生這個函式的 PHPUnit 測試骨架。
不需要完整實作,只需要:
1. 主要的測試情境(正常情況、邊界情況、錯誤情況)
2. 測試函式名稱
3. 注解說明每個測試要驗證什麼
我會在重構前後都跑這些測試,確認行為一致。
📈 實際成效
六個月持續改善的數字變化:
| 指標 | 改善前 | 6 個月後 |
|---|---|---|
| PHPStan 錯誤數 | 2,847 | 412 |
| 新程式碼 type coverage | ~10% | 90%+ |
| 「我機器可以跑」問題 | 每月幾次 | 幾乎沒有(Docker 化後) |
| 新成員 onboarding 時間 | 4 小時 | 30 分鐘 |
PHPStan 從 2,847 降到 412,是 6 個月每週順手修幾個的結果。不是專門衝這個數字,而是每次改動附近有問題就順便修。
新程式碼 type coverage 90%+ 則是靠 CLAUDE.md 禁止清單的規範效果——新程式碼強制要求型別標注。
🎉 結語
PHP 7.4 EOL 不代表要放棄改善程式碼品質。
版本升級和品質改善是兩條平行的路。就算引擎換不了,保養還是要做,而且保養做好了,未來換引擎的代價也會更小——更好的型別標注意味著升級時更容易找到不相容的地方。
PHPStan 從 2,847 到 412,不是靠一次大重構,而是每週順手改幾個。這就是「漸進式改善」的真諦:不求完美,但持續前進。
📎 相關文章: