Serving Large Files From PHP Safely
When a personal server hands out large files such as backup archives or videos through PHP, three problems come up repeatedly: output buffers (temporary holding areas where the server collects output before sending) eating memory, non-ASCII filenames breaking in the Content-Disposition header, and timeouts behind a CDN or reverse proxy (a relay server that receives requests on your server's behalf). These are the fixes proven in real operation code.
Clear Output Buffers and Stream in Chunks
Printing a big file with readfile() or file_get_contents() can pull the whole file into PHP output buffers and memory. Close every open output buffer before sending, then read and emit the file in 8 KB chunks - memory usage stays flat regardless of file size.
Content-Disposition That Keeps Non-ASCII Filenames Intact
Putting a UTF-8 filename directly into the filename value breaks in some browsers and download managers. The standard fix is to send both values: an ASCII-substituted filename for older clients, plus an RFC 5987 filename*=UTF-8 value for modern browsers.
Timeouts Behind a CDN or Reverse Proxy
CDNs such as Cloudflare drop the connection if the origin stays silent for a fixed window (about 100 seconds on the free plan). If preparing the file takes long, keep the connection alive by emitting small progress output instead of staying quiet. On Nginx, send X-Accel-Buffering: no so the output actually reaches the client in real time.
The function below combines all three fixes. Path validation (basename handling, allowed-folder checks) must be done separately before calling it.
function stream_file(string $file): void {
$name = basename($file);
$ascii = preg_replace('/[^\x20-\x7e]/', '_', $name);
$utf8 = rawurlencode($name);
while (ob_get_level()) {
ob_end_clean(); // close every open output buffer
}
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . $ascii . '"'
. "; filename*=UTF-8''" . $utf8);
header('Content-Length: ' . filesize($file));
header('Cache-Control: no-cache, no-store, must-revalidate');
header('X-Accel-Buffering: no'); // disable Nginx response buffering
$h = fopen($file, 'rb');
while (!feof($h)) {
echo fread($h, 8192); // stream in 8 KB chunks
flush();
}
fclose($h);
}
Operation Tips
For one-time downloads, call unlink() (PHP's file-delete function) after streaming finishes. Since downloads can be aborted, also clean old files in the temp folder with a cron job (a Linux scheduler that runs commands at set times). If you need resumable (Range) downloads, let Nginx serve the file via X-Accel-Redirect instead of PHP - it is far more efficient.