PHPのファイル読み書きは、「小さなファイルは file_get_contents/file_put_contents で一括」「大きなファイルは fopen+fgets か SplFileObject で1行ずつ」「CSVは fgetcsv/fputcsv」の3本を使い分ければ、ほとんどの用途をカバーできます。 一括で読む関数は手軽なかわりにファイル全体をメモリに載せ、1行ずつ読む方法はファイルの大きさに関係なく一定のメモリで動く、というのが選び分けの中心です。

この記事では、読み書きの関数を早見表にまとめたうえで、100万行(約66MB)のファイルで測ったメモリ使用量、fopenのモード10種類の動作確認、Excelで作ったShift_JISのCSVの読み込み、BOM付きUTF-8での書き出し、PHP 8.4で変わったescape引数、flockによる同時書き込み対策、ファイルが無いときの警告と例外化までを扱います。掲載したコードと出力は、すべて PHP 8.4.21(CLI、Linux)で動かして確かめたものです。

PHPファイル読み書きの早見表

まず「やりたいこと」から関数を引ける表です。

やりたいこと 書き方 戻り値・ポイント
ファイル全体を文字列で読む file_get_contents('a.txt') 文字列。読めないと false
全行を配列で読む file('a.txt', FILE_IGNORE_NEW_LINES) 行の配列。改行を外すフラグを付ける
1行ずつ読む fopen → while (($l = fgets($fp)) !== false) → fclose メモリは1行ぶんだけ
1行ずつ読む(オブジェクト) foreach (new SplFileObject('a.txt') as $l) 開けないと例外になる
指定バイト数だけ読む fread($fp, 1024) バイナリや固定長向け
上書きで書く file_put_contents('a.txt', $s) 書いたバイト数。無ければ作る
追記する file_put_contents('a.txt', $s, FILE_APPEND | LOCK_EX) 末尾に足す。ロックつき
少しずつ書く fopen('a.txt', 'w') → fwrite → fclose 大量の行を順に出すとき
CSVを1行読む fgetcsv($fp, null, ',', '"', '') 列の配列。escapeは明示する
CSVを1行書く fputcsv($fp, $row, ',', '"', '', "\r\n") 引用符の付与は自動
Shift_JISを読む stream_filter_append($fp, 'convert.iconv.CP932/UTF-8') 読みながらUTF-8に変換
同時書き込みを防ぐ flock($fp, LOCK_EX) 〜 flock($fp, LOCK_UN) 読んで書き戻す処理で使う
あるか調べる is_file() / is_readable() / is_writable() file_exists() はフォルダでも true
大きさを調べる filesize('a.txt') バイト数
消す・名前を変える・コピー unlink() / rename() / copy() 成功で true

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「PHPファイル読み込み・書き込み|fopen・CSV対応まとめ」

読み込み方法4つの比較とメモリ実測

4つの方法の違い(一括か1行ずつか)

読み込みの方法は大きく4つあります。違いは「一度に全部読むか、1行ずつ読むか」と「結果の形」です。

方法 読み方 結果の形 メモリ消費 向く用途
file_get_contents() 一括 1つの文字列 ファイルサイズとほぼ同じ 設定ファイル、JSON、テンプレート、数MBまでのテキスト
file() 一括 行の配列 ファイルサイズの2〜3倍 行数の少ないリスト、行番号で扱いたいとき
fopen()+fgets() 1行ずつ 1行の文字列 1行ぶん(数MBで一定) ログ、大きなCSV、途中で打ち切る処理
SplFileObject 1行ずつ 1行の文字列(foreach可) 1行ぶん(数MBで一定) 例外で扱いたい、行番号やシークを使いたい

100万行・約66MBのファイルで測った結果

次のスクリプトで、1,000,000行(65,778,896バイト)のログ風ファイルを作り、4つの方法と、fgetsを包んだジェネレータで行数を数えました。

<?php
// gen.php:テスト用の大きなファイルを作る
$fp = fopen('big.log', 'w');
for ($i = 1; $i <= 1000000; $i++) {
    fwrite($fp, sprintf("%07d,2026-10-03 12:00:00,INFO,order created id=%d user=%d\n", $i, $i, $i % 1000));
}
fclose($fp);
echo filesize('big.log'), " bytes\n";
<?php
// mem.php:php -d memory_limit=1G mem.php 方法名
$how = $argv[1];
$t = microtime(true);
$n = 0;
switch ($how) {
case 'file_get_contents':
    $s = file_get_contents('big.log'); $n = substr_count($s, "\n"); break;
case 'file':
    $a = file('big.log', FILE_IGNORE_NEW_LINES); $n = count($a); break;
case 'fgets':
    $fp = fopen('big.log', 'r');
    while (($line = fgets($fp)) !== false) { $n++; }
    fclose($fp); break;
case 'SplFileObject':
    $f = new SplFileObject('big.log');
    $f->setFlags(SplFileObject::DROP_NEW_LINE | SplFileObject::READ_AHEAD | SplFileObject::SKIP_EMPTY);
    foreach ($f as $line) { $n++; }
    break;
}
printf("%-18s 行数=%d ピークメモリ=%.1fMB 時間=%.2f秒\n",
    $how, $n, memory_get_peak_usage(true) / 1048576, microtime(true) - $t);

実行結果(PHP 8.4.21)。最後の generator は、fgetsのループをジェネレータ関数(yield)で包んだ場合の値です:

file_get_contents  行数=1000000 ピークメモリ=64.7MB 時間=0.05秒
file               行数=1000000 ピークメモリ=172.7MB 時間=0.27秒
fgets              行数=1000000 ピークメモリ=2.0MB 時間=0.06秒
SplFileObject      行数=1000000 ピークメモリ=2.0MB 時間=0.12秒
generator          行数=1000000 ピークメモリ=2.0MB 時間=0.13秒
方法 ピークメモリ 時間 読み取れること
file_get_contents() 64.7MB 0.05秒 ファイルサイズとほぼ同じだけ使う
file() 172.7MB 0.27秒 行ごとに文字列を持つため、ファイルサイズの約2.6倍
fopen()+fgets() 2.0MB 0.06秒 ファイルが大きくても増えない
SplFileObject 2.0MB 0.12秒 メモリはfgetsと同じ。時間は少し増える
ジェネレータ(fgetsを包む) 2.0MB 0.13秒 foreachで書けて、メモリは増えない

1行ずつ読む方法は、66MBのファイルでもピークメモリが2.0MBのままでした。 時間は1回の計測値で、ディスクキャッシュの状態によって前後します。目安として見てください。

メモリの上限(memory_limit)を32MBにして file() を呼ぶと、次のエラーで止まります。

PHP Fatal error:  Allowed memory size of 33554432 bytes exhausted (tried to allocate 65787120 bytes) in /work/mem.php on line 9

このエラーが出たときは、memory_limitを上げる前に、1行ずつ読む書き方に置き換えられないかを先に考えるのがおすすめです。ファイルが今後大きくなっても同じコードで動き続けます。

どれを選ぶかの判断表

状況 選ぶもの
数MBまでで、中身を丸ごと使う(JSON、設定、テンプレート) file_get_contents()
行数が少なく、配列関数(array_map など)で加工したい file()
サイズが読めない、または10MBを超えそう fopen()+fgets()
例外で扱いたい、行番号やシークを使いたい SplFileObject
CSV fopen()+fgetcsv()(引用符と改行を正しく扱える)
先頭の数行だけ見たい fopen()+fgets() を必要な回数だけ

ファイルを読み込む4つの書き方

ここからは、次の memo.txt(UTF-8、3行)を使います。

1行目
2行目
3行目

file_get_contentsで丸ごと読む

<?php
$text = file_get_contents('memo.txt');
if ($text === false) {
    exit("読み込めませんでした\n");
}
echo $text;
1行目
2行目
3行目

戻り値は文字列で、読めなかったときは false です。判定は必ず === false で行います。 空のファイルは空文字列 "" が返り、== で比べると false と同じ扱いになるためです(後半のエラー処理の節で実行結果を載せます)。

fileで行の配列にする

<?php
$lines = file('memo.txt', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
print_r($lines);
Array
(
    [0] => 1行目
    [1] => 2行目
    [2] => 3行目
)
フラグ 働き
FILE_IGNORE_NEW_LINES 各要素の末尾の改行を外す(付けないと "1行目\n" のように改行が残る)
FILE_SKIP_EMPTY_LINES 空行を飛ばす(FILE_IGNORE_NEW_LINES と一緒に使う)

fopenとfgetsで1行ずつ読む

<?php
$fp = fopen('memo.txt', 'r');
if ($fp === false) {
    exit("開けませんでした\n");
}
while (($line = fgets($fp)) !== false) {
    echo '[', rtrim($line, "\r\n"), "]\n";   // 行末の改行を外す
}
fclose($fp);
[1行目]
[2行目]
[3行目]

ループの条件は ($line = fgets($fp)) !== false と書きます。 while ($line = fgets($fp)) のように省略すると、内容が "0" の行で止まることがあります。a、0、b の3行のファイルで試した結果です。

while($line = ...) で読めた行数: 1
!== false で読めた行数: 3

while (!feof($fp)) で回す書き方もありますが、ファイルを開けなかったときに $fp が false のまま進んでしまうので、fgetsの戻り値で判定する形が扱いやすくなります。

SplFileObjectでforeachする

<?php
$file = new SplFileObject('memo.txt');
$file->setFlags(
    SplFileObject::DROP_NEW_LINE   // 行末の改行を外す
    | SplFileObject::READ_AHEAD    // 先読みする(SKIP_EMPTYと組み合わせる)
    | SplFileObject::SKIP_EMPTY    // 空行を飛ばす
);
foreach ($file as $no => $line) {
    echo $no, ': ', $line, "\n";
}
0: 1行目
1: 2行目
2: 3行目

SplFileObjectは、開けないときに警告ではなく例外(RuntimeException)を投げる点と、fcloseを書かなくても変数が破棄されるときに閉じられる点が特徴です。フラグを付けないと、最後に空の行が1つ余分に回ってきます。

freadで決まったバイト数だけ読む

<?php
$fp = fopen('memo.txt', 'r');
var_dump(fread($fp, 4));
fclose($fp);
string(4) "1行"

fread() の長さは文字数ではなくバイト数です。UTF-8の「行」は3バイトなので、4バイトで「1」と「行」が取れています。日本語のテキストを途中で切ると文字の途中で切れることがあるため、テキストは行単位(fgets)、画像などのバイナリはfread、という使い分けが合います。

ファイルに書き込む方法

file_put_contentsで上書き・追記する

<?php
// 上書き(無ければ作る)
$bytes = file_put_contents('memo.txt', "1行目\n2行目\n");
var_dump($bytes);

// 追記(末尾に足す)。LOCK_EX で書き込み中のロックも取る
file_put_contents('memo.txt', "3行目\n", FILE_APPEND | LOCK_EX);
int(16)
第3引数のフラグ 働き
なし 上書き(中身を空にしてから書く)
FILE_APPEND 末尾に追記
LOCK_EX 書き込みの間、排他ロックを取る
FILE_APPEND | LOCK_EX ログの追記でよく使う組み合わせ

戻り値は書き込んだバイト数です。保存先のフォルダが無いときは警告が出て false が返ります(フォルダは自動では作られません。mkdir($dir, 0775, true) で先に作ります)。

fopenとfwriteで少しずつ書く

件数の多いデータを順に書き出すときは、全体を1つの文字列にまとめずに、fwriteで1行ずつ出すとメモリを使いません。

<?php
$fp = fopen('export.txt', 'w');
if ($fp === false) {
    exit("開けませんでした\n");
}
foreach (['りんご', 'みかん', 'ぶどう'] as $i => $name) {
    fwrite($fp, ($i + 1) . ',' . $name . "\n");
}
fclose($fp);

fputs() は fwrite() の別名で、動作は同じです。改行は自動では付かないので、"\n"(または PHP_EOL)を自分で足します。

書いている途中の状態を見せない(一時ファイル+rename)

設定ファイルのように「読む側が、書きかけの中身を見ると困る」ものは、一時ファイルに書き切ってから rename で置き換える方法が向いています。同じフォルダ内の rename は一瞬で切り替わるため、読む側は書き換え前か書き換え後のどちらかを必ず読めます。

<?php
function writeAtomic(string $path, string $data): void
{
    $tmp = tempnam(dirname($path), 'tmp_');
    if ($tmp === false || file_put_contents($tmp, $data) === false) {
        throw new RuntimeException("一時ファイルに書けませんでした: {$path}");
    }
    if (!rename($tmp, $path)) {
        @unlink($tmp);
        throw new RuntimeException("置き換えられませんでした: {$path}");
    }
}

writeAtomic(__DIR__ . '/config.json', json_encode(['theme' => 'dark']));
echo file_get_contents(__DIR__ . '/config.json'), "\n";
{"theme":"dark"}

fopenのモード早見表(r・w・a・x・c と +)

fopenの第2引数(モード)は、「ファイルが無いとき作るか」「既存の中身を消すか」「最初の位置はどこか」の3点で整理すると覚えやすくなります。

モード 読み 書き 無いとき 既存の中身 最初の位置 主な使いどころ
r ○ − false(警告) そのまま 先頭 読み込み
r+ ○ ○ false(警告) そのまま 先頭 既存ファイルの一部を書き換える
w − ○ 作る 消す 先頭 新規作成・上書き
w+ ○ ○ 作る 消す 先頭 上書きしてから読み返す
a − ○ 作る そのまま 末尾(常に追記) ログの追記
a+ ○ ○ 作る そのまま 書き込みは常に末尾 追記しつつ読む
x − ○ 作る あると false(警告) 先頭 「まだ無いときだけ作る」
x+ ○ ○ 作る あると false(警告) 先頭 同上+読む
c − ○ 作る そのまま 先頭 flockしてから書き換える
c+ ○ ○ 作る そのまま 先頭 flockして読んで書き戻す

各モードの説明は、PHPマニュアルの fopen のページを今回開いて確認しました。さらに、中身が ABCDE のファイルを各モードで開いて xy を書き込み、結果を見るスクリプトを実行しました。

<?php
foreach (['r','r+','w','w+','a','a+','x','x+','c','c+'] as $m) {
    file_put_contents('exist.txt', "ABCDE");           // 既存ファイルあり
    $fp = @fopen('exist.txt', $m);
    if ($fp === false) {
        $ex = 'false';
    } else {
        $w = @fwrite($fp, 'xy');
        fclose($fp);
        $ex = sprintf("書込=%s 結果=%s", var_export($w, true), var_export(file_get_contents('exist.txt'), true));
    }
    @unlink('none.txt');                               // ファイルなし
    $fp = @fopen('none.txt', $m);
    $no = $fp === false ? 'false' : '作成';
    if ($fp) fclose($fp);
    printf("%-3s 既存: %-28s 無し: %s\n", $m, $ex, $no);
}
r   既存: 書込=false 結果='ABCDE'  無し: false
r+  既存: 書込=2 結果='xyCDE'      無し: false
w   既存: 書込=2 結果='xy'         無し: 作成
w+  既存: 書込=2 結果='xy'         無し: 作成
a   既存: 書込=2 結果='ABCDExy'    無し: 作成
a+  既存: 書込=2 結果='ABCDExy'    無し: 作成
x   既存: false                        無し: 作成
x+  既存: false                        無し: 作成
c   既存: 書込=2 結果='xyCDE'      無し: 作成
c+  既存: 書込=2 結果='xyCDE'      無し: 作成

読み取れるポイントは3つです。

  • w は開いた時点で中身が消えます。 「読んでから書き戻す」処理を w で始めると、読む前に空になります。その用途には c+ が合います。
  • r+ と c は先頭から上書きします。 ABCDE に xy を書くと xyCDE になり、後ろは残ります。全体を置き換えるなら ftruncate($fp, 0) で空にしてから書きます。
  • a は fseek で位置を動かしても、書き込みは常に末尾に足されます。

Windowsで画像などのバイナリを扱うときは、'rb'、'wb' のように b を付けると改行の変換が入らず、OSによる差がなくなります。この記事の検証はLinuxで行っており、Windowsでの動作は未実行です。

CSVを読み込む・書き出す

fgetcsvで読み、ヘッダー付きの連想配列にする

CSVは、カンマで区切られているだけに見えて、値の中にカンマ・改行・引用符が入ることがあります。explode(',', $line) ではこれを正しく分けられないため、fgetcsvを使います。

次の items.csv(UTF-8)を読みます。2行目は値の中にカンマと改行、3行目は引用符を含んでいます。

商品ID,商品名,備考
1,"ノート,A4","1行目
2行目"
2,髙橋商店①,"彼は""OK""と言った"
<?php
function readCsvAssoc(string $path): array
{
    $fp = fopen($path, 'r');
    if ($fp === false) {
        throw new RuntimeException("開けませんでした: {$path}");
    }
    try {
        // 先頭にBOMがあれば読み飛ばす
        if (fread($fp, 3) !== "\xEF\xBB\xBF") {
            rewind($fp);
        }
        $header = fgetcsv($fp, null, ',', '"', '');
        $rows = [];
        while (($r = fgetcsv($fp, null, ',', '"', '')) !== false) {
            if ($r === [null]) {
                continue;                       // 空行は [null] で返る
            }
            $rows[] = array_combine($header, $r);
        }
        return $rows;
    } finally {
        fclose($fp);
    }
}

echo json_encode(readCsvAssoc('items.csv'), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), "\n";
[
    {
        "商品ID": "1",
        "商品名": "ノート,A4",
        "備考": "1行目\n2行目"
    },
    {
        "商品ID": "2",
        "商品名": "髙橋商店①",
        "備考": "彼は\"OK\"と言った"
    }
]

値の中のカンマ・改行・引用符が、1つの値として取り出せています。値はすべて文字列で返るので、数値として使う列は (int) などで変換します。

fgetcsvの引数は次の5つです。

引数 意味 おすすめの指定
$stream fopenで開いたファイル −
$length 1行の最大長 null(制限なし)
$separator 区切り文字 ','(タブ区切りなら "\t")
$enclosure 囲み文字 '"'
$escape エスケープ文字 ''(空文字。次の節で説明)

PHP 8.4以降はescape引数を明示する

PHP 8.4から、fgetcsv・fputcsv・str_getcsv などで escape 引数を省略すると、非推奨(Deprecated)の通知が出るようになりました。 既定値の "\\"(バックスラッシュ)が、将来のバージョンで変わる予定のためです。PHPマニュアルの fgetcsv のページ(今回開いて確認)には、8.4.0で既定値に頼ることが非推奨になったこと、空文字列を明示するのがおすすめであること、既定値はPHP 9.0以降で変更予定であることが書かれています。

<?php
// php -d error_reporting=E_ALL -d display_errors=1 esc.php
$fp = fopen('php://memory', 'r+');
fwrite($fp, "a,b\n"); rewind($fp);

fgetcsv($fp);                     // 省略
fputcsv($fp, ['a', 'b']);         // 省略
str_getcsv('a,b');                // 省略
$s = new SplFileObject('php://memory', 'r+');
$s->fputcsv(['a', 'b']);          // 省略

echo "---明示すると出ない\n";
rewind($fp);
fgetcsv($fp, null, ',', '"', '');         // 位置で指定
fgetcsv($fp, escape: '');                 // 名前付き引数で指定
fputcsv($fp, ['a', 'b'], escape: '');
$s->setCsvControl(',', '"', '');          // SplFileObjectはここでまとめて指定
echo "done\n";

実行結果(PHP 8.4.21):

Deprecated: fgetcsv(): the $escape parameter must be provided as its default value will change in /work/esc.php on line 6
Deprecated: fputcsv(): the $escape parameter must be provided as its default value will change in /work/esc.php on line 7
Deprecated: str_getcsv(): the $escape parameter must be provided as its default value will change in /work/esc.php on line 8
Deprecated: SplFileObject::fputcsv(): the $escape parameter must be provided, as its default value will change, either explicitly or via SplFileObject::setCsvControl() in /work/esc.php on line 10
---明示すると出ない
done

escapeに何を渡すかで、書き出される内容も変わります。

<?php
$o = fopen('php://memory', 'r+');
fputcsv($o, ['dir\\"name', 'z'], ',', '"', '\\');   // escape = バックスラッシュ
fputcsv($o, ['dir\\"name', 'z'], ',', '"', '');     // escape = 空文字
rewind($o);
echo stream_get_contents($o);
"dir\"name",z
"dir\""name",z
escapeの指定 dir\"name の書き出し 特徴
'\\'(これまでの既定) "dir\"name" \" の引用符が二重にならない。表計算ソフトなどで列がずれることがある
''(空文字) "dir\""name" 引用符は必ず "" になる。RFC 4180の形

新しく書くコードでは escape: '' を選ぶと、他のソフトとのやり取りで形がそろいます。 既存のファイルをこれまでの既定値で書き出していて、同じPHPで読み戻している場合は、'\\' を明示すれば動作を変えずに通知だけ消せます。なお、名前付き引数の escape: '' はPHP 8.0以降、空文字のescapeはPHP 7.4以降で使えます。

Excelで作ったShift_JISのCSVを読み込む

Excelの「CSV(コンマ区切り)」で保存したファイルは、日本語版WindowsではShift_JIS(正確にはWindowsの拡張を含むCP932)・改行CRLFです。そのまま読むと、UTF-8としては読めないバイト列になります。

<?php
// Excelが出力する形(Shift_JIS・CRLF)のCSVを用意する
$utf8 = "商品ID,商品名,備考\r\n"
      . "1,\"ノート,A4\",\"1行目\r\n2行目\"\r\n"
      . "2,髙橋商店①,\"彼は\"\"OK\"\"と言った\"\r\n"
      . "3,表計算ソフト,能力\r\n";
file_put_contents('excel.csv', mb_convert_encoding($utf8, 'SJIS-win', 'UTF-8'));

// (A) 変換せずに読む
$fp = fopen('excel.csv', 'r');
$row = fgetcsv($fp, null, ',', '"', '');
fclose($fp);
echo "変換なし: ", json_encode($row, JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_UNICODE),
     " valid=", var_export(mb_check_encoding($row[1], 'UTF-8'), true), "\n";

// (B) ストリームフィルタで UTF-8 に変換しながら読む
$fp = fopen('excel.csv', 'r');
stream_filter_append($fp, 'convert.iconv.CP932/UTF-8', STREAM_FILTER_READ);
$header = fgetcsv($fp, null, ',', '"', '');
$rows = [];
while (($r = fgetcsv($fp, null, ',', '"', '')) !== false) {
    if ($r === [null]) continue;
    $rows[] = array_combine($header, $r);
}
fclose($fp);
echo json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), "\n";
変換なし: ["���iID","���i��","���l"] valid=false
[
    {
        "商品ID": "1",
        "商品名": "ノート,A4",
        "備考": "1行目\r\n2行目"
    },
    {
        "商品ID": "2",
        "商品名": "髙橋商店①",
        "備考": "彼は\"OK\"と言った"
    },
    {
        "商品ID": "3",
        "商品名": "表計算ソフト",
        "備考": "能力"
    }
]

stream_filter_append() で変換フィルタを付けると、1行ずつ読みながらUTF-8に変換できます。 ファイル全体をメモリに載せないので、大きなCSVにも使えます(iconv拡張が必要です。php -m に iconv があれば使えます)。

小さなファイルなら、一括で変換してからメモリ上のストリームで読む方法もあります。

<?php
$text = mb_convert_encoding(file_get_contents('excel.csv'), 'UTF-8', 'SJIS-win');
$mem = fopen('php://memory', 'r+');
fwrite($mem, $text);
rewind($mem);
$n = 0;
while (fgetcsv($mem, null, ',', '"', '') !== false) $n++;
fclose($mem);
echo "行数=$n\n";
行数=4

Shift_JISのCSVで知っておくとよい点を2つ挙げます。

1つ目は、変換元の名前に SJIS-win(または CP932)を使うことです。 SJIS を指定すると、「髙」「①」のようなWindows拡張の文字が変換できません。

<?php
$b = mb_convert_encoding('髙橋商店①', 'SJIS-win', 'UTF-8');
echo "SJIS-win→", mb_convert_encoding($b, 'UTF-8', 'SJIS-win'),
     " / SJIS→", mb_convert_encoding($b, 'UTF-8', 'SJIS'), "\n";
SJIS-win→髙橋商店① / SJIS→??橋商店?

2つ目は、先にUTF-8へ変換してからCSVとして解析することです。 Shift_JISでは「表」「能」などの2バイト目が 0x5C(バックスラッシュと同じ値)になります。「表」は16進で 955c です。Shift_JISのまま、escapeをバックスラッシュにして読むと、列の区切りがずれます。

<?php
$u = "1,\"表\",\"次の列\"\r\n";
file_put_contents('s2.csv', mb_convert_encoding($u, 'SJIS-win', 'UTF-8'));

$fp = fopen('s2.csv', 'r'); $r = fgetcsv($fp, null, ',', '"', '\\'); fclose($fp);
echo "escape='\\\\' 列数=", count($r), "\n";
$fp = fopen('s2.csv', 'r'); $r = fgetcsv($fp, null, ',', '"', ''); fclose($fp);
echo "escape=''   列数=", count($r), "\n";
escape='\\' 列数=2
escape=''   列数=3

3列のはずが2列になっています。変換フィルタを付けてUTF-8にしてから読むこと、escapeを空文字にすることの両方が、この現象への備えになります。

BOM付きUTF-8で書き出す(Excelで開ける形)

UTF-8のCSVをExcelでダブルクリックして開くと、文字コードが判別されずに文字化けすることがあります。ファイルの先頭にBOM(EF BB BF の3バイト)を付けると、ExcelがUTF-8と判別できます。

<?php
$rows = [
    ['商品ID', '商品名', '備考'],
    [1, 'ノート,A4', "1行目\n2行目"],
    [2, '髙橋商店①', '彼は"OK"と言った'],
];

$fp = fopen('out_utf8_bom.csv', 'w');
fwrite($fp, "\xEF\xBB\xBF");                       // BOM(先頭に1回だけ)
foreach ($rows as $row) {
    fputcsv($fp, $row, ',', '"', '', "\r\n");      // escape='' と eol="\r\n" を明示
}
fclose($fp);

echo bin2hex(substr(file_get_contents('out_utf8_bom.csv'), 0, 6)), "\n";
echo substr(file_get_contents('out_utf8_bom.csv'), 3);
efbbbfe59586
商品ID,商品名,備考
1,"ノート,A4","1行目
2行目"
2,髙橋商店①,"彼は""OK""と言った"

カンマ・改行・引用符を含む値だけが自動で " で囲まれ、値の中の " は "" になっています。fputcsvに任せると、囲む・囲まないの判断を自分で書かずに済みます。 第6引数の $eol(PHP 8.1以降)で改行を "\r\n" にしておくと、Windowsのソフトで開いたときの形がそろいます。

逆に、BOM付きのファイルを読むときは先頭3バイトを外します。 外さずに読むと、1列目の見出しにBOMが付いたままになり、$row['商品ID'] で取り出せません。

<?php
$fp = fopen('out_utf8_bom.csv', 'r');
$h1 = fgetcsv($fp, null, ',', '"', '');
var_dump($h1[0] === '商品ID', bin2hex($h1[0]));

rewind($fp);
if (fread($fp, 3) !== "\xEF\xBB\xBF") rewind($fp);   // BOMなら読み飛ばす
$h2 = fgetcsv($fp, null, ',', '"', '');
var_dump($h2[0] === '商品ID');
fclose($fp);
bool(false)
string(22) "efbbbfe59586e593814944"
bool(true)

見た目は同じ「商品ID」でも、先頭に efbbbf が付いていて一致しないことがわかります。

Shift_JISで書き出したいときは、書き込み側に変換フィルタを付けます。

<?php
$fp = fopen('out_sjis.csv', 'w');
stream_filter_append($fp, 'convert.iconv.UTF-8/CP932', STREAM_FILTER_WRITE);
foreach ($rows as $row) {
    fputcsv($fp, $row, ',', '"', '', "\r\n");
}
fclose($fp);
echo mb_detect_encoding(file_get_contents('out_sjis.csv'), ['UTF-8', 'SJIS-win'], true), "\n";
SJIS-win
書き出し形式 Excelで開く ほかのプログラムで読む 選ぶ場面
UTF-8(BOMなし) 文字化けすることがある そのまま読める プログラム同士の受け渡し
UTF-8(BOM付き) 開ける 先頭3バイトを外す処理が必要 人がExcelで開く。絵文字なども保てる
Shift_JIS(CP932) 開ける 変換が必要 受け取り側がShift_JISを指定しているとき

Excelで実際に開いての表示確認は、この検証環境にExcelが無いため未実行です。バイト列が意図どおりであることまでを確認しています。

flockで同時書き込みからデータを守る

ロックなしで「読んで書き戻す」とどうなるか

Webアプリでは、複数のリクエストが同じファイルを同時に扱うことがあります。カウンターのように「読む → 計算する → 書き戻す」処理は、間に別の処理が入ると、あとから書いた側が前の更新を上書きします。

<?php
// counter_nolock.php:ロックなし
for ($i = 0; $i < 2000; $i++) {
    $n = (int) file_get_contents('counter.txt');
    file_put_contents('counter.txt', (string) ($n + 1));
}

counter.txt を 0 にしてから、このスクリプトを4つ同時に実行しました(2000回×4=8000になるはずです)。

nolock: 801
nolock 2回目: 11

8000にならず、実行のたびに違う値になりました。上書きで中身が空になった瞬間を別の処理が読み、0として数え直すことも起きています。

flockで守る書き方

<?php
// counter_lock.php:flockあり
for ($i = 0; $i < 2000; $i++) {
    $fp = fopen('counter.txt', 'c+');      // 無ければ作る・中身は消さない
    if (!flock($fp, LOCK_EX)) {            // 取れるまで待つ
        throw new RuntimeException('ロックを取得できませんでした');
    }
    try {
        $n = (int) stream_get_contents($fp);
        ftruncate($fp, 0);                 // ロックを取ってから中身を消す
        rewind($fp);
        fwrite($fp, (string) ($n + 1));
        fflush($fp);                       // ロックを外す前に書き切る
    } finally {
        flock($fp, LOCK_UN);
        fclose($fp);
    }
}

同じく4つ同時に実行した結果です。

lock: 8000

ポイントは次の4つです。

ポイント 理由
モードは c+ w だとロックを取る前に中身が消える。c+ は消さずに開ける
flock($fp, LOCK_EX) のあとで読む 読む前にロックを取ることで、読んでから書くまでを1つのまとまりにできる
ftruncate → rewind → fwrite 中身を空にして先頭から書く。桁数が減ったときに前の文字が残らない
fflush してから LOCK_UN バッファに残った分を書き切ってからロックを外す

flockの種類は次のとおりです。

指定 意味 使う場面
LOCK_SH 共有ロック(読む人同士は同時に取れる) 書き込み中の中身を読まないようにする
LOCK_EX 排他ロック(1つだけ取れる) 書き込み、読んで書き戻す処理
LOCK_UN ロックを外す 処理の最後
LOCK_EX | LOCK_NB 待たずにすぐ結果を返す 取れなければ別の動きをしたいとき

LOCK_NB を付けると、ほかがロック中のときに待たずに false が返り、第3引数に 1 が入ります。

<?php
$fp = fopen('counter.txt', 'c+');
flock($fp, LOCK_EX);
$fp2 = fopen('counter.txt', 'c+');
$ok = flock($fp2, LOCK_EX | LOCK_NB, $wouldBlock);
var_dump($ok, $wouldBlock);
bool(false)
int(1)

flockは「flockを使う処理同士」で順番を守る仕組みです(アドバイザリロックと呼ばれます)。flockを呼ばずに書き込む処理は止められないので、同じファイルを扱うコードはすべてflockを通す形にそろえます。

追記だけなら FILE_APPEND と LOCK_EX

ログのように末尾へ足すだけの処理は、file_put_contents($path, $line, FILE_APPEND | LOCK_EX) の1行で書けます。4つのプロセスから200バイトの行を5000回ずつ追記し、行が混ざらないかを数えました。

app_nolock.log 行数=20000 混ざった行=0
app_lock.log 行数=20000 混ざった行=0

今回の条件(Linux、ローカルディスク、1行200バイト)では、LOCK_EXなしでも行は混ざりませんでした。ただし、これは今回の環境での結果です。1行が長い場合や、ネットワーク越しのファイルシステムでは同じになるとは限らないため、追記には LOCK_EX を付けておく形をおすすめします。

ファイルが無いときの警告と例外化

そのまま呼ぶと警告が出て false が返る

存在しないファイルを読もうとしたときの動きを確認します(パスは /work/ に短縮しています)。

<?php
$s = file_get_contents('nai.txt');
var_dump($s);

$fp = fopen('nai.txt', 'r');
var_dump($fp);

try {
    fgets($fp);
} catch (TypeError $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}

$r = file_put_contents('nodir/a.txt', 'x');   // フォルダが無い
var_dump($r);
Warning: file_get_contents(nai.txt): Failed to open stream: No such file or directory in /work/err.php on line 3
bool(false)

Warning: fopen(nai.txt): Failed to open stream: No such file or directory in /work/err.php on line 5
bool(false)
TypeError: fgets(): Argument #1 ($stream) must be of type resource, false given

Warning: file_put_contents(nodir/a.txt): Failed to open stream: No such file or directory in /work/err.php on line 8
bool(false)

ファイル関数の多くは、例外ではなく「警告(Warning)+戻り値 false」で知らせます。 戻り値を確かめずに進むと、PHP 8以降では次の fgets(false) の時点で TypeError になります。エラーの原因は1つ前の fopen にあるので、fopenの直後で false を確かめるのが近道です。

よく見る警告文と、確認する場所をまとめます。

警告文(一部) 意味 確認すること
Failed to open stream: No such file or directory ファイルかフォルダが無い パス、カレントディレクトリ(__DIR__ で組み立てる)
Failed to open stream: Permission denied 権限が無い ファイルと親フォルダの権限、実行ユーザー
Failed to open stream: File exists x モードで既にある 意図どおりなら別名にする
Allowed memory size of ... exhausted メモリの上限を超えた 1行ずつ読む書き方へ

相対パスは「実行した場所」からの位置で解決されます。Webとコマンドラインで実行場所が変わるので、__DIR__ . '/data/a.txt' のようにスクリプトの場所を基準に組み立てると安定します。

事前チェックと戻り値チェックで例外にする

読み込みを関数にまとめ、読めないときは例外を投げる形にすると、呼び出し側は try〜catch だけで扱えます。

<?php
function readText(string $path): string
{
    if (!is_file($path) || !is_readable($path)) {
        throw new RuntimeException("読み込めるファイルがありません: {$path}");
    }
    $text = @file_get_contents($path);
    if ($text === false) {
        $msg = error_get_last()['message'] ?? '原因不明';
        throw new RuntimeException("読み込めませんでした: {$path} ({$msg})");
    }
    return $text;
}

try {
    readText('nai.txt');
} catch (RuntimeException $e) {
    echo '捕まえた: ', $e->getMessage(), "\n";
}
捕まえた: 読み込めるファイルがありません: nai.txt

事前に is_file() で確かめても、確かめた直後にファイルが消える可能性は残ります。そのため、戻り値の === false チェックも合わせて書く形にしています。@ は警告の表示を抑える演算子で、ここでは直後に戻り値を調べて例外に変えているので、情報は失われません。

警告をまとめて ErrorException に変える

アプリ全体で「警告は例外として扱う」と決めるなら、set_error_handler() で一括変換できます。

<?php
set_error_handler(function (int $no, string $msg, string $file, int $line): bool {
    if (!(error_reporting() & $no)) {
        return false;                    // @ で抑制された分は対象外にする
    }
    throw new ErrorException($msg, 0, $no, $file, $line);
});

try {
    $fp = fopen('nai.txt', 'r');
} catch (ErrorException $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
} finally {
    restore_error_handler();
}
ErrorException: fopen(nai.txt): Failed to open stream: No such file or directory

SplFileObjectは最初から例外になる

<?php
try {
    new SplFileObject('nai.txt');
} catch (RuntimeException $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}
RuntimeException: SplFileObject::__construct(nai.txt): Failed to open stream: No such file or directory
方法 向く場面
戻り値を === false で確かめる 小さなスクリプト、1か所だけの読み書き
関数にまとめて例外を投げる 読み書きを何か所かで使う
set_error_handler で一括変換 アプリ全体で方針をそろえる(フレームワークでは標準で入っていることが多い)
SplFileObject 最初から例外で扱いたい

空のファイルと false を区別する

<?php
file_put_contents('empty.txt', '');
$t = file_get_contents('empty.txt');
var_dump($t, $t == false, $t === false);
string(0) ""
bool(true)
bool(false)

空のファイルは正常に読めて空文字列が返ります。== では false と等しいと判定されるので、=== false で比べると「読めなかった」と「空だった」を分けられます。

つまずきやすい点と直し方

症状 原因 直し方
最後の行に改行が付いたまま比較が合わない fgets() や file() は改行を含めて返す rtrim($line, "\r\n")、FILE_IGNORE_NEW_LINES
ループが途中で止まる while ($line = fgets($fp)) で "0" の行が偽になる !== false で比べる
読む前に中身が空になる w で開いた c+ で開き、flock後に ftruncate
CSVの列がずれる explode(',') で分けた、またはShift_JISのまま解析した fgetcsv、UTF-8へ変換してから解析
CSVの1列目だけ連想配列で取れない 先頭にBOMが付いている 先頭3バイトを読み飛ばす
「髙」「①」が ? になる 変換名が SJIS SJIS-win か CP932
Deprecatedが大量に出る(8.4以降) CSV関数のescapeを省略している escape: '' を明示
メモリ不足で止まる file() や file_get_contents() で大きなファイルを読んだ fgets か SplFileObject
開発環境では動くのに開けない 相対パスの基準が違う __DIR__ から組み立てる
filesize() の値が書き込み後も変わらない 結果がキャッシュされている clearstatcache() を呼ぶ

フォームやURLから受け取った文字列をそのままファイル名に使うと、../ を含む値で想定外の場所を読める状態になります。ファイル名を外部から受け取るときは、basename() でフォルダ部分を落とす、または決めておいた一覧と照らし合わせる形にしておくと安心です。

よくある質問

Q. file_get_contents と fopen はどちらを使えばよいですか? A. 数MBまでで中身を丸ごと使うなら file_get_contents が手軽です。サイズが読めない、または大きくなりそうなファイルは fopen+fgets を選ぶと、今回の計測では66MBのファイルでもピークメモリが2.0MBで一定でした。

Q. fgetcsv で Deprecated が出るようになりました。どう直しますか? A. PHP 8.4から、escape引数の省略が非推奨になったためです。fgetcsv($fp, null, ',', '"', '') のように第5引数を明示するか、fgetcsv($fp, escape: '') と名前付き引数で指定すると出なくなります。これまでの動作を変えたくない場合は '\\' を明示します。

Q. ExcelのCSVが文字化けします。 A. 読むときは、ExcelのCSVがShift_JIS(CP932)であることが多いので、stream_filter_append($fp, 'convert.iconv.CP932/UTF-8') でUTF-8に変換しながら読みます。書くときは、先頭にBOM("\xEF\xBB\xBF")を付けたUTF-8にすると、ExcelがUTF-8と判別できます。

Q. flock は必ず必要ですか? A. 1つの処理しか触らないファイルなら不要です。複数のリクエストから「読んで書き戻す」ファイルには必要で、今回の検証ではロックなしだと8000になるはずの値が801や11になりました。追記だけなら FILE_APPEND | LOCK_EX の指定で足ります。

まとめ

  • 小さなファイルは file_get_contents/file_put_contents、大きなファイルは fopen+fgets か SplFileObject で1行ずつ読む
  • 100万行・66MBの計測では、file() が172.7MB、fgets は2.0MB。サイズが読めないときは1行ずつが安心
  • fopenのモードは「無ければ作るか・中身を消すか・位置」で選ぶ。読んで書き戻すなら c+
  • CSVは fgetcsv/fputcsv に任せ、PHP 8.4以降は escape を明示(新しいコードは空文字)
  • Shift_JISは CP932/SJIS-win でUTF-8に変換してから解析し、Excel向けの書き出しはBOM付きUTF-8
  • 同時に書き込まれるファイルは flock(LOCK_EX) で守り、追記は FILE_APPEND | LOCK_EX
  • ファイル関数は警告+falseで知らせるので、=== false を確かめるか、例外に変えて扱う

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「PHPファイル読み込み・書き込み|fopen・CSV対応まとめ」

株式会社RJCでは、PHPのファイル読み書きやCSVの扱い方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?