CSVインポート時の改行の崩れを防ぐ方法

はじめに

PHPの組み込み関数 fgetcsv() は、ダブルクォートで囲まれたフィールド内部の改行を正しく処理できない場合があります。Excelなどで作成されたCSVに住所・コメント欄などが含まれると、1行が複数行に分割されてDBへの登録が壊れることがあります。

問題が起きやすいケース

例えば以下のようなCSVデータがあるとします。

"山田太郎","東京都
渋谷区 1-2-3","備考なし"

住所フィールドに改行が含まれているため、標準の fgetcsv() では2行に分割されてしまい、以降のデータがすべてズレます。期待する読み取り結果は下記の通りです。

名前: 山田太郎
住所: 東京都\n渋谷区 1-2-3
備考: 備考なし

カスタムパーサーで解決

クォートの開閉を追跡し、クォートが閉じるまで複数行を結合して読み込む関数を実装します。ダブルクォートの出現数が偶数になるまで fgets() を繰り返すことで、フィールド内の改行をまたいで1レコードとして認識します。

** * 改行を含むフィールドに対応したCSVパーサー * * @param resource $handle fopen() で開いたファイルポインタ * @param int|null $length 1行あたりの最大読み取りバイト数 * @param string $d 区切り文字(デフォルト: カンマ) * @param string $e 囲み文字(デフォルト: ダブルクォート) * @return array|false フィールド配列、またはEOFでfalse */public function fgetcsv_reg (&$handle, $length = null, $d = ',', $e = '"') { $d = preg_quote($d); $e = preg_quote($e); $_line = ""; $eof = false; // クォートが偶数個になる(= 全フィールドが閉じている)まで行を結合 while (($eof != true) and (!feof($handle))) { $_line .= (empty($length) ? fgets($handle) : fgets($handle, $length)); $itemcnt = preg_match_all('/'.$e.'/', $_line, $dummy); if ($itemcnt % 2 == 0) $eof = true; } // 行末の改行を区切り文字に置換してパース準備 $_csv_line = preg_replace('/(?:\r\n|[\r\n])?$/', $d, trim($_line)); $_csv_pattern = '/('.$e.'[^'.$e.']*(?:'.$e.$e.'[^'.$e.']*)*'.$e.'|[^'.$d.']*)'.$d.'/'; preg_match_all($_csv_pattern, $_csv_line, $_csv_matches); $_csv_data = $_csv_matches[1]; // クォート除去 & エスケープされた "" を " に戻す for ($_csv_i = 0; $_csv_i < count($_csv_data); $_csv_i++) { $_csv_data[$_csv_i] = preg_replace('/^'.$e.'(.*)'.$e.'$/s', '$1', $_csv_data[$_csv_i]); $_csv_data[$_csv_i] = str_replace($e.$e, $e, $_csv_data[$_csv_i]); } return empty($_line) ? false : $_csv_data;}
PHP

文字コード変換込みの読み込み

Excelが出力するCSVはShift_JIS(SJIS-WIN)エンコードが多いため、読み込み後にUTF-8へ変換します。

// アップロードされたファイル名をSJISに変換(サーバーの環境に応じて調整)$name = mb_convert_encoding($name, 'SJIS', 'UTF-8');$filepath = './tmp/' . $name;$handle = fopen($filepath, "r");$records = [];while (($line = $this->fgetcsv_reg($handle)) !== false) { // 各フィールドをSJIS-WIN → UTF-8に変換 mb_convert_variables("UTF-8", "SJIS-WIN", $line); $records[] = $line;}fclose($handle); // 忘れずにファイルを閉じる
PHP

処理の流れ

  1. ファイル名の文字コード変換
    サーバーのファイルシステムがSJISを要求する場合、ファイルパスをUTF-8 → SJISに変換してから fopen() に渡します。
  2. fgetcsv_reg() でレコードを読み取り
    クォート内の改行を検出し、フィールドが正しく閉じるまで複数行を結合。標準の fgetcsv() では崩れていたレコードも正確に取得します。
  3. mb_convert_variables() でUTF-8へ変換
    配列内の全要素を一括でUTF-8に変換。mb_convert_encoding() を各フィールドに個別適用するより簡潔に書けます。
  4. $records 配列をDBへ投入
    正規化された配列をループしてINSERT / upsertするだけ。改行を含むフィールドもそのままDBに保存できます。

応用・カスタマイズ例

タブ区切り(TSV)に対応

第3引数に区切り文字を渡すだけでTSVにも対応できます。

// タブ区切りの場合$line = $this->fgetcsv_reg($handle, null, "\t");
PHP

文字コード自動判定

ファイルのエンコードが不明な場合は mb_detect_encoding() で判定してから変換できます。

while (($line = $this->fgetcsv_reg($handle)) !== false) { foreach ($line as &$field) { $enc = mb_detect_encoding($field, ['UTF-8', 'SJIS-WIN', 'EUC-JP'], true); if ($enc !== 'UTF-8') { $field = mb_convert_encoding($field, 'UTF-8', $enc); } } $records[] = $line;}
PHP

ヘッダー行をキーとして連想配列に変換

1行目をカラム名として使い、各レコードを連想配列に変換すると扱いやすくなります。

$headers = null;$records = [];while (($line = $this->fgetcsv_reg($handle)) !== false) { mb_convert_variables('UTF-8', 'SJIS-WIN', $line); if ($headers === null) { $headers = $line; // 1行目をヘッダーとして保持 continue; } // ヘッダー名をキーにした連想配列に変換 $records[] = array_combine($headers, $line);}// 例: $records[0]['氏名'], $records[0]['住所'] でアクセス可能
PHP

Laravel / フレームワークへの組み込み

Laravelなど現代的なフレームワークでは Storage::path() でパスを取得してから渡せます。

use Illuminate\Support\Facades\Storage;$path = Storage::path('uploads/' . $request->file('csv')->store('uploads'));$handle = fopen($path, 'r');$importer = new CsvImporter();$records = [];while (($line = $importer->fgetcsv_reg($handle)) !== false) { mb_convert_variables('UTF-8', 'SJIS-WIN', $line); $records[] = $line;}fclose($handle);
PHP

補足・注意点

  • 大きなファイルのメモリ対策:$length パラメータを指定すると1回の fgets() の読み取りバイト数を制限できます。数万行を超えるCSVでは特に有効です。
  • fclose() を忘れずに:ループ後は必ず fclose($handle) でファイルポインタを解放してください。解放漏れはリソースリークの原因になります。
  • BOM 付き UTF-8 への対処:Excelが出力するUTF-8 CSVにはBOM(\xEF\xBB\xBF)が付く場合があります。先頭行の最初のフィールドに ltrim($field, "\xEF\xBB\xBF") を適用すると安全です。
  • PHP 8.x での動作確認:PHP 8.0以降では fgetcsv() のデフォルト引数の扱いが変更されています。カスタム実装のほうが互換性の観点でも安定しています。
  • league/csv ライブラリの活用も検討:本番運用では league/csv などの実績あるライブラリも選択肢です。より複雑な要件(ストリーミング処理・バリデーション)が生じた場合に有効です。

read next