- PHP
PHPファイル読み込み・書き込み|fopen・CSV対応まとめ
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期連続の黒字決算。