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/

這樣做的效果:

  1. 今天的 2,847 個問題不再報出來(它們在 baseline 裡)
  2. 往後任何新增的程式碼,如果引入同類問題,會立刻被抓到
  3. 每次修復舊問題並從 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,847412
新程式碼 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,不是靠一次大重構,而是每週順手改幾個。這就是「漸進式改善」的真諦:不求完美,但持續前進。


📎 相關文章