When a client asked me to let their customers download 2 GB PDFs stored outside the public folder, my first attempt using response()->download() blew up with a PHP memory error. The server timed out, the download never finished, and the client was furious. The root cause was that Laravel tried to read the whole file into memory before sending it. For large, private assets you need a streaming response that reads the file chunk-by-chunk, respects the web server’s timeout settings, and never exposes the path to the end user.

Why a simple download() won’t cut it for big files

response()->download($path) is convenient for small assets because Laravel wraps Symfony’s BinaryFileResponse. Under the hood it calls setFile() which opens the file, determines its size and then lets the web server stream it. That works fine until the file size exceeds the PHP memory limit or the web server’s buffer. When the file is several gigabytes the process tries to allocate a buffer the size of the file, hits the memory ceiling and aborts.

The error you’ll see looks like:

Allowed memory size of 134217728 bytes exhausted (tried to allocate 10485760 bytes)

Even if you crank up memory_limit, the request can still exceed the default max_execution_time. The result is a half-downloaded file that leaves the client with a corrupted PDF.

Stream the file instead of loading it all at once

Laravel gives you a StreamedResponse class that lets you write a callback. The callback is executed while the HTTP response is being sent, so you can read a few kilobytes at a time and flush them to the client. The browser sees a normal download, but PHP never holds more than the chunk you read.

use SymfonyComponentHttpFoundationStreamedResponse;

function downloadPrivateFile(string $filePath, string $fileName): StreamedResponse {
    return response()->streamDownload(function () use ($filePath) {
        $handle = fopen($filePath, 'rb');
        if ($handle === false) {
            abort(404, 'File not found');
        }
        while (!feof($handle)) {
            // read 8 KB at a time
            echo fread($handle, 8192);
            // flush the output buffer to the client immediately
            flush();
        }
        fclose($handle);
    }, $fileName);
}
  • fopen($filePath, 'rb') opens the file in binary read mode.
  • fread(..., 8192) reads an 8 KB chunk; you can bump this to 64 KB for faster throughput.
  • flush() forces PHP to push the buffer to the web server, which then streams it to the client.
  • The callback runs until feof() signals end-of-file, guaranteeing the whole file is sent.

Protect the file location

The file lives outside public/, for example in storage/app/private/. Laravel’s default filesystems configuration already treats that directory as "local" and not publicly accessible. The controller method should resolve the absolute path via Storage::disk('local')->path($relativePath) so you never expose a raw filesystem path in the URL.

use Illuminate\Support\Facades\Storage;

public function show($id) {
    $record = File::findOrFail($id);
    $path   = Storage::disk('local')->path($record->private_path);
    return downloadPrivateFile($path, $record->original_name);
}
  • Storage::disk('local')->path() converts the stored relative path to an absolute one.
  • The controller checks the user’s permission before calling the streaming helper.

Handling range requests for resumable downloads

Modern browsers and download managers often request a specific byte range so the user can pause and resume. Symfony’s BinaryFileResponse already supports range headers, but our custom streamed response does not. Adding range support is a bit of extra work, but it makes the download experience much smoother for large files.

function streamFileWithRange(string $filePath, string $fileName): StreamedResponse {
    $size = filesize($filePath);
    $start = 0;
    $length = $size;
    $status = 200;

    if (isset($_SERVER['HTTP_RANGE'])) {
        // Example: bytes=500-999
        preg_match('/bytes=(\d+)-(\d*)/', $_SERVER['HTTP_RANGE'], $matches);
        $start = (int)$matches[1];
        $end   = $matches[2] !== '' ? (int)$matches[2] : $size - 1;
        $length = $end - $start + 1;
        $status = 206; // Partial Content
    }

    return response()->stream(function () use ($filePath, $start, $length) {
        $handle = fopen($filePath, 'rb');
        fseek($handle, $start);
        $remaining = $length;
        while ($remaining > 0 && !feof($handle)) {
            $read = min(8192, $remaining);
            echo fread($handle, $read);
            flush();
            $remaining -= $read;
        }
        fclose($handle);
    }, $status, [
        'Content-Type'        => mime_content_type($filePath),
        'Content-Disposition' => "attachment; filename=\"$fileName\"",
        'Content-Length'      => $length,
        'Accept-Ranges'       => 'bytes',
        'Content-Range'       => "bytes $start-" . ($start + $length - 1) . "/$size",
    ]);
}
  • Parses the Range header to calculate $start and $length.
  • Sets HTTP status 206 and appropriate Content-Range header.
  • Uses fseek to jump to the requested offset before streaming.

Common pitfalls and how to avoid them

  1. Output buffering, If you have ob_start() somewhere (common in Laravel middleware), the buffer may swallow the flush() calls. Disable buffering for the route:
    Route::get('download/{id}', 'FileController@show')->withoutMiddleware('StartSession');
    
  2. Web server limits, Nginx’s fastcgi_buffer_size and proxy_buffering can truncate large streams. Set fastcgi_max_temp_file_size 0; and proxy_buffering off; for the download location.
  3. Filesystem permissions, The PHP process must be able to read the private directory. A mis-set permission throws a 500 error that looks like “Failed to open stream”.
  4. HTTPS and chunked encoding, Some older reverse proxies mishandle chunked responses over TLS. If you see truncated files only on HTTPS, enable proxy_request_buffering off; in the proxy.

Testing the download flow

  • Local dev: Use php artisan serve and a small 10 MB file first. Verify the response headers with Chrome DevTools → Network.
  • Staging: Deploy the same code to a staging server that mirrors production Nginx/Apache config. Test a 2 GB file with a download manager that supports resume.
  • Automated: Write a PHPUnit test that calls the route and asserts the Content-Type, Content-Disposition, and that the response body length matches the file size (use stream_get_contents on the response stream).

FAQ

How do I prevent a user from guessing the download URL?

Store the file path in the database and generate a signed, time-limited URL using Laravel’s URL::temporarySignedRoute. The controller validates the signature before streaming.

My download still times out on very large files. What else can I tweak?

Increase the web server’s client_body_timeout and send_timeout. On Nginx, proxy_read_timeout 3600; gives the client an hour to finish. Also consider raising PHP’s max_execution_time to 0 (unlimited) for the route.

Can I use Laravel’s built-in Storage::download() for private files?

Storage::download() ultimately calls response()->download(), which suffers the same memory issue for huge files. Stick with a streamed response or a dedicated file-server like S3 pre-signed URLs if you need massive scalability.

Wrapping up

If you ever need to serve gigabyte-scale assets that live behind authentication, ditch the one-liner download helper and stream the file yourself. The pattern is simple: open a binary handle, read a modest chunk, flush, and repeat. Add range-header support for resumable downloads, keep the file out of public/, and lock down the route with signed URLs. With those pieces in place the user gets a reliable download experience and you avoid costly memory errors.