PHP file download looks trivial at first glance: read the file, echo it, done. But the moment you try to serve a few-hundred-megabyte log archive or a video file this way, server memory fills up instantly, the PHP process dies with Allowed memory size exhausted, and the user is left with a half-finished download. In this article I walk through how to serve files with the right headers and how to stream large files without exhausting memory.
Why the naive approach blows up memory
The most common mistake is loading the entire file into memory at once:
// BAD: a 500 MB file = 500 MB of RAM
$data = file_get_contents('/path/to/big.zip');
echo $data;
Combining file_get_contents (or buffering before echo) copies the whole file into PHP's memory space. If your memory_limit is 256 MB, a 300 MB file kills the process before the download even starts. The fix is to read the file chunk by chunk and write each chunk to the output immediately, i.e. to stream it. That way only a small buffer lives in memory at any moment.
The correct download headers
To make the browser download the file instead of displaying it, you need to send the right HTTP headers. The critical ones are:
Content-Type— the file's MIME type (useapplication/octet-streamif unknown).Content-Disposition: attachment; filename="..."— tells the browser to download it and under which name to save it.Content-Length— the file size; required for the progress bar to work correctly.
$file = '/path/to/big.zip';
$name = 'archive.zip';
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . $name . '"');
header('Content-Length: ' . filesize($file));
header('X-Content-Type-Options: nosniff');
If the filename in Content-Disposition contains spaces or non-ASCII characters, it is good practice to also add the filename*=UTF-8'' syntax for compatibility with older browsers.
Simple streaming with readfile
For medium-sized files PHP's readfile function is enough. readfile sends the file straight to the output buffer without pulling it into PHP memory; however, that advantage is lost if output buffering is on. So clear the buffer first:
// Close any open output buffers
while (ob_get_level() > 0) {
ob_end_clean();
}
readfile($file);
exit;
readfile handles the job in one line but gives you no control over the stream. If you need rate limiting, resumable downloads, or per-chunk processing, you have to switch to manual reading.
Full control with fopen/fread
For truly large files and full control, read the file in fixed-size chunks. The loop below uses only 8 KB of memory at a time:
$handle = fopen($file, 'rb');
if ($handle === false) {
http_response_code(404);
exit;
}
while (!feof($handle)) {
echo fread($handle, 8192); // 8 KB chunks
flush(); // push the buffer to the client now
}
fclose($handle);
exit;
Opening the file in 'rb' (binary) mode prevents corruption of binary files like zips and images on Windows servers. The flush() call sends data without holding it back. For very large downloads you can add an if (connection_aborted()) break; check per iteration so you stop reading uselessly if the user closes the tab.
Execution time and security
On a slow connection a large file can exceed the default max_execution_time (usually 30 s). Reset the limit before streaming begins; set_time_limit(0) can refresh the timer per chunk, but the safer approach is to call set_time_limit periodically inside the stream loop.
On the security side, the most critical point is never to put user input straight into the file path:
- Block path traversal: sanitize inputs like
../../etc/passwdwithbasename()and verify the file sits in the allowed directory withrealpath(). - Use a whitelist: map downloadable files to an ID in the database; never expose raw paths to the client.
- Check authorization: verify the user has access to that file before starting the stream.
$base = '/var/www/downloads';
$path = realpath($base . '/' . basename($_GET['file'] ?? ''));
if ($path === false || strpos($path, $base) !== 0) {
http_response_code(403);
exit('Access denied');
}
The practical route in Laravel and Symfony
If you use a framework, you don't need to do this by hand. In Laravel, response()->download() serves the file as a download, while response()->streamDownload() streams generated content (an on-the-fly CSV, for example) without filling memory. On the Symfony side, BinaryFileResponse does the same and supports the X-Sendfile header.
// Laravel
return response()->download('/path/to/big.zip', 'archive.zip');
// Content generated on the fly
return response()->streamDownload(function () {
$out = fopen('php://output', 'w');
foreach ($rows as $row) {
fputcsv($out, $row);
}
fclose($out);
}, 'report.csv');
For maximum performance, the best option is to never pass static files through PHP at all and hand them off to the web server: with X-Accel-Redirect on Nginx or the X-Sendfile header on Apache, PHP does the authorization check while the server itself delivers the file. This keeps a PHP worker from being tied up for seconds at a time.
Frequently Asked Questions
Should I use readfile or an fread loop?
For medium-sized files, and once you've cleared the output buffer, readfile is enough and the simplest path. If you need fine-tuning like rate limiting, resumable downloads, or connection-abort detection, the fopen/fread loop gives you full control.
My download cuts off and I get a "corrupt file" error, why?
The most common cause is whitespace or a BOM character leaking into the output before the file. Make sure you print nothing before the headers, close any open ob_* buffers, and ensure Content-Length matches the real size exactly.
Why does the server slow down when many users download at once?
Each active download keeps a PHP worker busy. For large static files, delegate delivery to the web server with X-Accel-Redirect (Nginx) or X-Sendfile (Apache); that way PHP workers are freed quickly.
Is your download infrastructure causing trouble? I can turn memory-blowing downloads into safe, fast streams. Get in touch and let's review your project together.