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