公開日 2026-09-27

PHPで大容量ファイルを安全にストリーミング配信する

PHPで数百MBのファイルをメモリ安全にダウンロード配信する最小構成。freadループで確保メモリを一定に保つ実装、長さとファイル名のヘッダ設計、日本語名を壊さないRFC 5987、CLIでの確認手順までを示す。

目次

  1. 動作を確認した構成
  2. なぜ一括読み込みではだめか
  3. 配信の本体
  4. コードのポイント
  5. 日本語ファイル名を壊さない Content-Disposition
  6. コードのポイント
  7. 配信を組み込んで確認する
  8. フレームワークの中で使うときの注意
  9. 本番(PHP-FPM)で使うときの注意
  10. まとめ

対象読者: PHP で数百 MB クラスのファイルをダウンロードさせるエンドポイントを書きたい方。フレームワークのファイル配信ヘルパーに頼らず、素の PHP で最小構成を組む場面を想定します。

数百 MB のファイルを file_get_contents() で読んで echo すると、リクエスト1本でその容量ぶんのメモリを確保します。memory_limit はリクエストごとの上限なので、ファイルがその上限を超えれば、同時アクセスを待たずその1本で落ちる。上限内に収まっても、同時配信が増えればワーカーごとの確保量がプロセスやホスト全体で積み上がります。どちらの経路でも効くのが、ファイルをメモリに載せきらず、少しずつ読んで送る配信です。この記事では、streamDownload() という1つの関数を題材に、メモリを一定に保つ読み出し、途中で離脱した相手への対応、日本語ファイル名を壊さない Content-Disposition の3点をコードで示します。

Range リクエスト(途中から再開するダウンロード)と、認証・認可、保存先を公開領域の外に置く設計は扱いません。ここで組むのは「読めるファイルを、メモリ安全に、正しいヘッダで流す」ところまでです。このコードは Range ヘッダを解釈せず、要求されても常に先頭から全体を 200 OK で返します(再開はできませんが、返す中身は壊れません)。この関数は、他の出力より前に呼ぶ専用のエンドポイントで使う前提です。既に本文の出力が始まっていると header() が効かず、先行したバイトがファイルへ混ざります。

動作を確認した構成

コードを動かして確認したのは、次の構成です。

  • PHP 8.4(CLI 版のビルトインサーバー php -S で配信を確認)

PHP のローカル環境がまだない場合の作り方は Windows 11で始めるPHPローカル開発環境(WSL2 + Docker + PostgreSQL) にまとめてあります。

なぜ一括読み込みではだめか

先に、メモリの振る舞いを実測で見ておく。200 MiB のファイルを2つの方法で読み、ピークメモリ(memory_get_peak_usage(true))を比べました。memory_limit はどちらも 64 MiB です。

一括で読む echo file_get_contents($path); は、ファイル全体を1つの文字列に載せようとします。

Fatal error: Allowed memory size of 67108864 bytes exhausted (tried to allocate 209723424 bytes)

64 MiB の上限に対して約 200 MiB を確保しようとして落ちました。次に、8 KiB ずつ読んで送る方法です。

stream(fread 8KB): peak=2.0 MiB / file=200.0 MiB

200 MiB のファイルでも、ピークは 2.0 MiB に収まります。一度に持つのは読み出しバッファの数 KiB だけなので、機構上はファイルサイズが増えても PHP が確保するメモリは変わりません(この記事の実測は 200 MiB まで)。メモリを一定に保てば、同時アクセスのときに memory_limit 超過で落ちる筋を塞げます。

なお、ここで測ったのは php -S(CLI ビルトインサーバー)での PHP のヒープメモリです。本番の PHP-FPM + nginx / Apache では、この後のヘッダやタイムアウトの扱いが変わる点があるので、そこは最後の「本番(PHP-FPM)で使うときの注意」で分けて書きます。

配信の本体

ファイルを少しずつ読んで送る関数です。src/download.php に置きます。

<?php
declare(strict_types=1);

namespace App\Download;

function streamDownload(string $path, string $downloadName, int $chunkSize = 8192): void
{
    if (!is_file($path) || !is_readable($path)) {
        http_response_code(404);
        return;
    }

    $handle = fopen($path, 'rb');
    if ($handle === false) {
        http_response_code(500);
        return;
    }

    while (ob_get_level() > 0) {
        if (!ob_end_clean()) {
            break;
        }
    }

    $size = fstat($handle)['size'];

    header('Content-Type: application/octet-stream');
    header('Content-Length: ' . $size);
    header('Content-Disposition: ' . contentDisposition($downloadName));
    header('X-Content-Type-Options: nosniff');
    header('Cache-Control: no-store');

    set_time_limit(0);
    ignore_user_abort(true);

    try {
        while (!feof($handle)) {
            $buffer = fread($handle, $chunkSize);
            if ($buffer === false) {
                break;
            }
            echo $buffer;
            flush();

            if (connection_aborted()) {
                break;
            }
        }
    } finally {
        fclose($handle);
    }
}

コードのポイント

① 出力バッファを空にしてから流す

    while (ob_get_level() > 0) {
        if (!ob_end_clean()) {
            break;
        }
    }

ob_start() や output_buffering の設定で出力バッファが有効だと、echo した内容は送信されずにメモリへ溜まります。溜めたまま大容量を流すと、少しずつ読んでいる意味が消え、結局ファイル全体をメモリに抱えることになる。ob_get_level() が 0 になるまで ob_end_clean() を繰り返し、開いているバッファをすべて閉じてから配信に入る。多段でバッファが積まれていることもあるため、1回ではなくループで落とします。注意したいのは、ob_end_clean() が閉じられないバッファ(zlib.output_compression が張るものなど)に対して false を返し、そのとき ob_get_level() が下がらない点です。戻り値を見ずに回すと無限ループになるため、失敗したらループを抜けます。

② fread ループと flush() でメモリを一定に保つ

        while (!feof($handle)) {
            $buffer = fread($handle, $chunkSize);
            if ($buffer === false) {
                break;
            }
            echo $buffer;
            flush();

一度に持つのは fread() で読んだ $chunkSize(既定 8 KiB)だけです。echo した内容を flush() で押し出せば、PHP が抱える書き込みバッファにも溜め込みません。この形なら、機構上はファイルがどれだけ大きくても、PHP が確保するメモリはチャンク1つぶんのままです。

ここで一定に保てるのは PHP が確保するメモリで、クライアントへ逐次届くかどうかは別の話です。php -S では flush() がそのままソケットへ書きますが、PHP-FPM + nginx / Apache では flush() は PHP と Web サーバーの境界までしか押し出しません。その先で nginx の fastcgi_buffering / proxy_buffering が挟まると、応答がまとめてバッファされてから届きます。メモリ側(PHP のヒープ)とエンドツーエンドの到達は分けて考えてください。

③ ignore_user_abort(true) で離脱を能動的に検知する

    set_time_limit(0);
    ignore_user_abort(true);

connection_aborted() の分岐は、ignore_user_abort(true) とセットで初めて意味を持ちます。PHP の既定(ignore_user_abort は Off)では、クライアントが切断したあと次に echo / flush() で出力しようとした時点で、PHP がスクリプトを打ち切ります。このとき配信ループの if (connection_aborted()) には到達せず、finally の fclose() も走りません(手元で php -S に curl --max-time で途中切断して確認: 既定では finally が実行されなかった)。

ignore_user_abort(true) を入れると、切断後もスクリプトが続くので、connection_aborted() を自分で見てループを抜けられます。離脱した相手へ大容量を最後まで読み続けるのを止め、finally の後片付け(ファイルを閉じる)も確実に通ります。set_time_limit(0) は、この1リクエストの PHP 実行時間の上限(max_execution_time)を外す指定です。タイムアウトの全体像は最後の節でまとめます。

日本語ファイル名を壊さない Content-Disposition

Content-Disposition の filename は、素の値に日本語やスペースを入れると、ブラウザやプロキシで文字化けしたり途中で切れたりする。ASCII だけの filename と、RFC 5987 形式の filename*(UTF-8)を併記し、対応するクライアントには filename* を優先させます。src/download.php に次の関数を足します。

<?php
// (src/download.php に追記。namespace App\Download; のまま)

function contentDisposition(string $downloadName): string
{
    $fallback = preg_replace('/[^\x20-\x7e]/', '_', $downloadName) ?? '';
    $fallback = str_replace(['\\', '"'], '_', $fallback);
    if ($fallback === '') {
        $fallback = 'download';
    }
    $encoded = rawurlencode($downloadName);

    return sprintf(
        "attachment; filename=\"%s\"; filename*=UTF-8''%s",
        $fallback,
        $encoded,
    );
}

コードのポイント

① ASCII フォールバックと filename* を併記する

    $fallback = preg_replace('/[^\x20-\x7e]/', '_', $downloadName) ?? '';
    $fallback = str_replace(['\\', '"'], '_', $fallback);
    if ($fallback === '') {
        $fallback = 'download';
    }
    $encoded = rawurlencode($downloadName);

$fallback は、印字可能な ASCII(\x20〜\x7e)以外を _ に置き換えた保険用の名前です。この置換は制御文字(\r / \n など \x20 未満)も _ にするので、$downloadName 経由のヘッダ注入は塞がります。バックスラッシュとダブルクオートも潰すのは、filename="..." のクオートを壊さないため。名前が全部 ASCII 外だと $fallback が空になるので、そのときは download に寄せます。filename* 側は rawurlencode() で UTF-8 のまま %エンコードし、UTF-8'' に続けたもの。この2つを併記すると、filename* を理解するクライアントは日本語名で受け取り、古いクライアントは ASCII フォールバックへ落ちます。$downloadName には UTF-8 の表示名を渡す前提で、パス(/ を含む文字列)はここへ渡しません。

配信を組み込んで確認する

エントリーポイントから関数を呼びます。public/index.php を作成します。ここでは storage/report.bin を レポート 2026.bin という名前で配信します。

<?php
declare(strict_types=1);

require __DIR__ . '/../src/download.php';

use function App\Download\streamDownload;

streamDownload(__DIR__ . '/../storage/report.bin', 'レポート 2026.bin');

確認用のファイルを用意します。ここでは 200 MiB のダミーを置きます。

mkdir -p storage
head -c 209715200 /dev/urandom > storage/report.bin

ビルトインサーバーで起動します。

php -S 0.0.0.0:8080 public/index.php

この起動では public/index.php をルータースクリプトとして渡しているので、パスに関わらず全リクエストがこのスクリプトを通り、配信されます。次の /download はルーティングされているわけではなく、確認用の URL にすぎません(実運用では $_SERVER['REQUEST_URI'] などでルートを判定します)。

別のターミナルから、ヘッダと中身を確認します。-D - でレスポンスヘッダを表示し、-o でファイルへ保存します。

curl -s -D - -o downloaded.bin http://localhost:8080/download

ヘッダは次のようになります。

HTTP/1.1 200 OK
Content-Type: application/octet-stream
Content-Length: 209715200
Content-Disposition: attachment; filename="____________ 2026.bin"; filename*=UTF-8''%E3%83%AC%E3%83%9D%E3%83%BC%E3%83%88%202026.bin
X-Content-Type-Options: nosniff
Cache-Control: no-store

filename*=UTF-8''%E3%83%AC... は レポート 2026.bin を %エンコードした値で、対応するブラウザはこちらを使って日本語名で保存します。filename="____________ 2026.bin" は、日本語部分を _ に置き換えた ASCII フォールバックです。

Content-Length は、パスではなく開いたハンドルの fstat() から取っています。同じパスへ配信中に別サイズのファイルが差し替わっても、宣言する長さは読んでいるハンドルのものに揃う。配信中にその実体が切り詰め・追記された場合は、宣言長と実際に送るバイト数がずれて、ダウンロードがハングしたり途中で切れたりします。配信対象は完了まで変更しない前提で使ってください(差し替えるなら別名で保存してから参照を切り替えます)。

保存したファイルが元と一致することも見ておきます。

md5sum storage/report.bin downloaded.bin
e2014180c87081bf9d2bea864fca7e07  storage/report.bin
e2014180c87081bf9d2bea864fca7e07  downloaded.bin

チャンクに分けて送っても、受け取った中身は元のファイルと同じです(md5 は手元のダミーファイルによって変わります)。

フレームワークの中で使うときの注意

フレームワーク(Laravel / CodeIgniter 4 など)のコントローラーからこの配信をするときは、レスポンスの扱いに1つ気をつける点があります。多くのフレームワークは、コントローラーが返したレスポンスオブジェクトを受け取ってから、後処理のミドルウェアやフィルタでヘッダを付けたり本文を送ったりする。ところが streamDownload() は自分で echo して本文を送るため、この後処理と二重になります。

配信し終えたら、フレームワークの後段を通さないように処理を止めます。ここで exit を使って打ち切ると、飛ぶのはヘッダ付与だけではありません。フレームワークによっては terminate 処理・セッションの書き込み・監査ログなど、後段でやるはずの後片付けも一緒に飛びます。だから「配信後は必ず exit」と一律にはできず、フレームワークごとに、レスポンスの送り方・後段で付くヘッダ・終了フックを確認したうえで止め方を決めます。この記事で X-Content-Type-Options: nosniff を関数の中で明示的に付けているのは、後段に任せず自前で出す前提にしているからです。

本番(PHP-FPM)で使うときの注意

この記事の実測はすべて php -S(CLI ビルトインサーバー)です。本番の PHP-FPM + nginx / Apache では、次の3つが別々に効くので、CLI の結果をそのまま持ち込む前に確認してください。

  • タイムアウト: set_time_limit(0) が外すのは PHP の max_execution_time だけです。PHP-FPM の request_terminate_timeout はこれとは独立にワーカーを強制終了し、上流の nginx の fastcgi_read_timeout / proxy_read_timeout(Apache mod_proxy_fcgi なら ProxyTimeout)も別に効きます。低速回線への大容量配信が途中で切れるときは、これらも合わせて見直します。
  • バッファリング: nginx は既定で proxy_buffering / fastcgi_buffering が有効なので、flush() してもクライアントへ逐次届かず、まとめてバッファされてから流れます。逐次到達させたい配信では、その経路に X-Content-Type-Options などと並べて header('X-Accel-Buffering: no'); を出し、nginx 側のバッファリングを切ります。同じ理由で、connection_aborted() によるクライアント切断の検知にも遅れが出ます。PHP が離脱を知るのは書き込みが nginx のバッファ境界へ達した時点で、バッファリング有効のままだと「切れた相手への配信を止める」動作が即座には効きません。この即時性が要るなら、逐次配信と同じく X-Accel-Buffering: no(または nginx 側の proxy_buffering off)を併せておきます。
  • 出力圧縮: zlib.output_compression が有効だと、Content-Length に載せた非圧縮サイズと実際に流れる圧縮後のバイト数が食い違い、ダウンロードが壊れます。この配信では出力圧縮を無効にしておきます。

まとめ

大容量ファイルを配信するときの3点を、コードで示しました。

  • file_get_contents() で一括して読まず、fread ループ+flush() で PHP が確保するメモリをチャンク1つぶんに保つ(php -S の実測で 200 MiB のファイルがピーク 2.0 MiB)
  • 配信前に ob_end_clean() で出力バッファを空にし、ignore_user_abort(true) + connection_aborted() で離脱した相手への配信を止める
  • Content-Disposition は ASCII の filename と RFC 5987 の filename* を併記し、日本語ファイル名を壊さない

本番の PHP-FPM + nginx では、タイムアウト・バッファリング・出力圧縮が CLI と別に効きます。前節の3点を確認してから載せてください。

保存先を公開領域の外に置いて直リンクを塞ぐ設計は、この配信の前段に置く別のテーマです。ファイルの受け取り側(アップロードの検証と保存)は Laravelでファイルアップロードを扱う(Storage + validation) を参照してください。