PHPのfputcsvは末尾がバックスラッシュの値で行が壊れるescape引数を空文字にしてPHP8.4の非推奨を消す
fputcsv()・fgetcsv()・str_getcsv()の第5引数$escapeは、デフォルトのバックスラッシュのままだと、値の末尾に「\」があるだけでCSVが壊れる原因になります。PHP 8.4ではこの引数を省略すること自体が非推奨になったので、空文字を明示するのが現実的な対処です。
CSVの出力と読み込みを両方PHPでやっているなら、まず問題は出ません。ところが、Windowsのパス(C:\temp\)や正規表現の断片のような、バックスラッシュで終わる値を含むデータが混ざった瞬間に、行がまるごと一つの項目に溶けることがあります。私は「CSVはPHPの標準関数に任せておけば安全」と思い込んでいたので、この挙動を知ったときは少し驚きました。手元のPHP 8.3.6で確かめた結果をまとめます。
末尾がバックスラッシュの値を書くとなぜ読み戻しが壊れるのか
PHPのCSV関数には、RFC 4180にはない独自の「エスケープ文字」の仕組みがあります。デフォルトはバックスラッシュで、直後の囲み文字(ダブルクォート)を「項目の終わりではなく文字」として扱います。この仕組みのせいで、値が「\」で終わると、書き出し側が付けた閉じのダブルクォートが、読み込み側から見ると「エスケープされた文字」になってしまいます。
<?php
$fh = fopen('php://memory', 'w+');
fputcsv($fh, ['a\\', 'b']); // 1項目目の値は a\ (末尾がバックスラッシュ)
fputcsv($fh, ['c', 'd']);
rewind($fh);
echo stream_get_contents($fh);
// "a\",b
// c,d
rewind($fh);
while (($row = fgetcsv($fh)) !== false) {
echo json_encode($row), "\n";
}
// ["a\\\",b\nc,d\n"] ← 2行4項目のはずが、1行1項目になる
書き出された1行目は「”a\”,b」で、閉じのダブルクォートの直前にバックスラッシュがあります。読み込み側はこれを「エスケープされたダブルクォート」と解釈し、項目がまだ終わっていないと判断して、次の行のカンマや改行まで飲み込んでしまいます。結果として、2行4項目のデータが、1行1項目として返ってきます。エラーも例外も出ないので、気づきにくいタイプの不具合です。
escapeを空文字にすると何が変わるのか
$escapeに空文字を渡すと、このPHP独自のエスケープ機構が無効になり、囲み文字は「ダブルクォートを2つ重ねる」というRFC 4180どおりの方法だけで扱われます(空文字の扱いはPHP 7.4から)。書き出しと読み込みの両方で揃えて渡すのがポイントです。
<?php
$fh = fopen('php://memory', 'w+');
fputcsv($fh, ['a\\', 'b'], ',', '"', '');
fputcsv($fh, ['c', 'd'], ',', '"', '');
rewind($fh);
echo stream_get_contents($fh);
// a\,b
// c,d
rewind($fh);
while (($row = fgetcsv($fh, null, ',', '"', '')) !== false) {
echo json_encode($row), "\n";
}
// ["a\\","b"]
// ["c","d"]
今度は「a\」がそのまま書かれ、2行ともきちんと読み戻せました。引数を並べるのが面倒なら、PHP 8.0以降の名前付き引数で escape: ” とだけ書けます。
<?php
fputcsv($fh, $row, escape: '');
$row = fgetcsv($fh, escape: '');
$cols = str_getcsv($line, escape: '');
PHP 8.4で何が非推奨になったのか
PHP 8.4では、$escapeを省略して既定値に頼ること自体が非推奨(E_DEPRECATED)になりました。fputcsv()、fgetcsv()、str_getcsv()のほか、SplFileObjectのCSV系メソッドも対象です。公式マニュアルによると、既定値は将来のバージョン(早くてもPHP 9.0)で変わる予定とされています。
注意したいのは、空文字にすると読み方が変わる点です。たとえば「”x\”y”,z」という行は、escapeがバックスラッシュのときと空文字のときで、次のように解釈が違います。
<?php
$line = '"x\"y",z';
echo json_encode(str_getcsv($line, ',', '"', '\\')), "\n"; // ["x\\\"y","z"] (x\"y)
echo json_encode(str_getcsv($line, ',', '"', '')), "\n"; // ["x\\y\"","z"] (x\y")
前者は「\”」を項目の途中のダブルクォートと読み(値は x\”y になります)、後者は「\」をただの文字、「”」を項目の終わりと読みます(値は x\y” になります)。つまり、他のシステムが「\”」でエスケープしたCSVを出力してくるなら、空文字にすると逆に壊れます。相手のCSVがどちらの流儀かを確かめてから決める必要があります。
見分け方としては、Excelやほとんどの一般的なCSVライブラリはダブルクォートを重ねる流儀なので、新規に書く側は空文字で揃えるのが無難です。バックスラッシュ流儀のデータを既存システムから受け取るときだけ、’\\’ を明示します。どちらにしても「明示する」ことが大事で、非推奨の警告も消えます。
まとめ
CSVを書き出すときは、escapeに空文字を渡して、RFC 4180どおりにしておくのが一番事故が少ないと思います。読む側は、相手の流儀に合わせて空文字か ‘\\’ を明示します。PHP 8.4で警告が出始めたのをきっかけに、既存コードをgrepして一度揃えておくと、あとで楽になるという気がしています。
よくある質問
Q. PHP 8.4にすると、CSVの出力結果が変わりますか?
A. 8.4で変わるのは警告が出るようになる点だけで、既定値は従来どおりバックスラッシュです。出力の中身は、escapeを明示しない限り変わりません。
Q. 既存のCSVを読むコードにも escape: ” を付けてよいですか?
A. 付けてよいのは、そのCSVがダブルクォートを重ねる形式のときだけです。バックスラッシュでエスケープされた項目が含まれるデータは、’\\’ を明示したままにしてください。
Q. SplFileObjectでCSVを読む場合も同じですか?
A. 同じです。setCsvControl()の第3引数でescapeを指定でき、PHP 8.4以降は省略すると非推奨になります。