はじめに
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処理の流れ
- ファイル名の文字コード変換
サーバーのファイルシステムがSJISを要求する場合、ファイルパスをUTF-8 → SJISに変換してからfopen()に渡します。 - fgetcsv_reg() でレコードを読み取り
クォート内の改行を検出し、フィールドが正しく閉じるまで複数行を結合。標準のfgetcsv()では崩れていたレコードも正確に取得します。 - mb_convert_variables() でUTF-8へ変換
配列内の全要素を一括でUTF-8に変換。mb_convert_encoding()を各フィールドに個別適用するより簡潔に書けます。 - $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]['住所'] でアクセス可能PHPLaravel / フレームワークへの組み込み
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などの実績あるライブラリも選択肢です。より複雑な要件(ストリーミング処理・バリデーション)が生じた場合に有効です。