在 Legacy PHP 專案中,「匯出 Excel」大概是被低估最多的功能。聽起來很簡單——查資料、寫儲存格、輸出檔案。但實際做起來,你會遇到中文亂碼、HTML 殘留、公式差一格、身分證外洩……每一個都是血淚教訓。
這篇整理了六個從真實專案中踩過的 Excel 匯出坑,以及如何用 Claude Code 加速修復。
背景:20+ 個匯出檔案的歷史包袱
專案中有超過 20 個 PHP 檔案使用 PHPExcel(後來的 PhpSpreadsheet)處理 Excel 匯出。大部分寫於多年前,使用舊的 Excel5(.xls)格式。問題是:
- 不同時期不同人寫的,風格不統一
- 有些直接用
header()輸出 HTML 假裝是 Excel - 有些用 PHPExcel 正規產生
- 中文處理方式各不相同
坑一:Excel5 升級 Excel2007——做了、revert、再做
問題
舊的 .xls 格式(Excel5)有 65,536 列限制,某些報表已經超過了。需要升級到 .xlsx(Excel2007)。
聽起來只要改兩行:
// 改 writer 類型
$writer = PHPExcel_IOFactory::createWriter($objPHPExcel, 'Excel2007');
// 改 Content-Type 和副檔名
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
header('Content-Disposition: attachment; filename="report.xlsx"');
實際踩到的坑
改完部署後,中文全部變亂碼。原因是 Excel2007 格式對字元編碼更嚴格,舊資料中的控制字元(\x00-\x1F)在 Excel5 下被默默忽略,在 Excel2007 下直接讓檔案損壞。
// ✅ 需要額外處理
$value = special_decode_string($rawValue);
$value = preg_replace('/[\x00-\x1F\x7F]/u', '', $value);
時間線
- Day 1:改了,部署
- Day 2:使用者回報亂碼,revert
- 一週後:加上字元清理,重新部署,成功
教訓:格式升級不只是改 writer 類型。每個字串值都可能藏有看不見的控制字元。
Claude Code 的角色
> Excel5 升級 Excel2007 後中文亂碼,幫我查可能的原因
Claude Code 指出 Excel2007(基於 XML)不允許控制字元,建議用 preg_replace 清理。這個知識點你要自己 Google 會花不少時間。
坑二: 滲入 Excel 儲存格
問題
匯出的 Excel 儲存格中出現了 文字(不是空格,是字面的 )。
根因
資料來自 HTML 富文字編輯器,儲存時包含 HTML 標籤。匯出時用 strip_tags() 移除標籤,但 不是標籤,不會被移除:
// ❌ strip_tags 不處理 HTML entities
$content = strip_tags($htmlContent);
// "<p>Hello World</p>" → "Hello World"
更麻煩的是,有些   沒有分號(是的,HTML 允許這樣):
<!-- 兩種都會出現 -->
Hello World
Hello  World
修復
// ✅ 先處理 HTML entities,再 strip_tags
$content = str_replace(
['</div>', '<br>', ' ', ' '],
["\n", "\n", ' ', ' '],
$htmlContent
);
$content = strip_tags($content);
$content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8');
處理順序很重要:先替換已知的 entities( ),再 strip_tags,最後 html_entity_decode 處理剩餘的。
Claude Code 的角色
> 匯出 Excel 有 亂碼,資料來自 HTML 編輯器
Claude Code 一次指出三個問題:strip_tags 不處理 entities、  有帶分號和不帶分號兩種、處理順序需要先 replace 再 strip。
坑三:COUNTA 公式差一筆——23 查到 22
問題
畫面上查詢結果顯示 23 筆,匯出的 Excel 報表底部合計卻顯示 22 筆。
根因
寫入 Excel 時用一個 $row 變數追蹤行號。迴圈結束後,$row 指向最後一筆資料的下一行。寫 COUNTA 公式時:
// ❌ $row 已經多加了 1,再 -1 就少了最後一筆
$countFormula = "=COUNTA(D5:D" . ($row - 1) . ")";
假設資料從第 5 行到第 27 行(23 筆),迴圈結束時 $row = 28。公式變成 =COUNTA(D5:D27),看起來對?不對——因為在某些地方 $row 在迴圈結束後又被加了 1(用來寫空白分隔行),實際 $row = 29,$row - 1 = 28……這種 off-by-one 在複雜的匯出邏輯中極其難追。
修復
// ✅ 直接用最後一筆資料的行號
$countFormula = "=COUNTA(D5:D" . $row . ")";
為什麼 Claude Code 有優勢
這種 Bug 需要追蹤 $row 在整個檔案中的每一次修改。在 300 行的匯出檔案裡,$row++ 可能出現在五六個地方。Claude Code 會把每一次 $row 變化列出來,算出最終值。
坑四:身分證字號外洩——匯出檔忘了遮蔽
問題
電子發票匯出功能直接把完整的身分證字號寫進 Excel。依規定,匯出檔中身分證後四碼應該用 **** 遮蔽。
修復
// ❌ 原本直接使用完整身分證
$memo = $data['id_no'] . '-' . $data['level'];
// ✅ 後四碼遮蔽
$maskedId = substr($data['id_no'], 0, -4) . '****';
$memo = $maskedId . '-' . $data['level'];
看起來很簡單,但問題是你要先知道有這個問題。在 20 幾個匯出檔案中,哪些有身分證欄位?哪些已經遮蔽、哪些沒有?
Claude Code 的角色
> 掃描所有匯出 Excel 的 PHP 檔案,找出包含身分證字號但沒有遮蔽的地方
Claude Code 一次掃完所有匯出檔案,列出每一個使用 id_no 欄位的位置和處理方式。
坑五:從 HTML 列印改成 Excel 匯出——減了 500 行
問題
某個報表原本用 HTML <table> 產生,配合 @media print CSS 列印。但使用者要求「可以在 Excel 裡編輯」。原始的 HTML 列印版有 740 行 PHP。
做法
Claude Code 建議的策略:用 Excel 範本檔 + 資料填入,取代從零建立儲存格。
// ✅ 載入預製的 .xlsx 範本
$spreadsheet = IOFactory::load('templates/report_template.xlsx');
$sheet = $spreadsheet->getActiveSheet();
// 填入資料
$row = 5; // 範本中資料起始行
foreach ($records as $record) {
$sheet->setCellValue("A{$row}", $record['date']);
$sheet->setCellValue("B{$row}", $record['name']);
$sheet->setCellValue("C{$row}", $record['amount']);
$row++;
}
// 輸出
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
$writer->save('php://output');
範本檔用 Excel 先做好格式(欄寬、表頭、框線、字型),PHP 只負責填資料。
結果:740 行 HTML 列印 → 300 行 Excel 匯出。格式穩定,使用者可以編輯。
坑六:匯入功能的逐行回饋——讓使用者知道哪行出錯
問題
批次匯入功能(從 Excel 匯入資料到系統)的第一版只告訴使用者「匯入完成」或「匯入失敗」。使用者不知道 500 筆裡哪幾筆有問題。
改進
Claude Code 幫忙設計了逐行回饋機制:
$importLogs = [];
foreach ($excelRows as $i => $row) {
try {
// 驗證 + 寫入
$existing = findByIdNo($row['id_no']);
if ($existing) {
$importLogs[] = [
'row' => $i,
'status' => 'skip',
'message' => "Excel 姓名:{$row['name']} ↔ 系統已存在:{$existing['name']}"
];
continue;
}
insertRecord($row);
$importLogs[] = ['row' => $i, 'status' => 'success', 'message' => ''];
} catch (Exception $e) {
$importLogs[] = ['row' => $i, 'status' => 'error', 'message' => $e->getMessage()];
}
}
// 存入 session,重導後顯示
$_SESSION['import_logs'] = $importLogs;
前端用顏色標示:✓ 成功(綠)/ ⊘ 略過(黃)/ ✗ 失敗(紅),附上原因。
Excel 匯出的踩坑清單
從這些案例整理出的檢查清單:
編碼與格式
- 使用 Excel2007 (
.xlsx) 格式,不要用 Excel5 (.xls) - 所有字串值清理控制字元:
preg_replace('/[\x00-\x1F\x7F]/u', '', $value) - HTML entities 在寫入前轉換:先
str_replace已知 entities,再html_entity_decode -
strip_tags()要在 entity 處理之後,不是之前
公式與計算
- 行號追蹤要明確:迴圈結束後
$row指向哪裡? - 合計公式的範圍要驗證:匯出後實際打開 Excel 確認
個資保護
- 身分證字號後四碼遮蔽
- 聯絡電話考慮部分遮蔽
- 掃描所有匯出檔案確認沒有遺漏
架構建議
- 優先使用範本檔(
.xlsx),不要從零建立 - 匯入功能要有逐行回饋
- 大量資料使用串流寫入,避免記憶體爆掉
結語
Excel 匯出看起來是最「無聊」的功能,但它是使用者每天都在用的東西。匯出錯一格,可能就是帳對不上、報表送錯。
Claude Code 在這件事上最有價值的地方是全局掃描——當你有 20 幾個匯出檔案,每個的寫法都不太一樣,人力逐一檢查非常耗時。讓 Claude Code 一次掃完,列出所有不一致的地方,再逐一修正,效率差非常多。