skip to content

In Laravel, how do you use response()->streamDownload() to export a million-row sales report as CSV without building it in memory?

level: middleimportance: must knowfreq 42%

answer

  1. write rows while the response is sent
  2. streamDownload($callback, $name, $headers, $disposition)
  3. fputcsv() to php://output
  4. lazy() or cursor() instead of get()
  5. exceptions wrapped in StreamedResponseException

basics

~20 s

response()->streamDownload($callback, 'sales.csv') returns a StreamedResponse with an attachment Content-Disposition. Its callback runs while the response is sent, writing rows with fputcsv to php://output from a lazy() or cursor() query, so no full file or array is ever held.

solid answer

~40 s

`streamDownload($callback, $name = null, $headers = [], $disposition = 'attachment')` returns a Symfony `StreamedResponse` with status 200 and a `Content-Disposition` built from `$name`, including an ASCII fallback. Inside the callback, open `php://output`, write a header row with `fputcsv()`, then iterate the sales with `Sale::query()->orderBy('id')->lazy(2000)` or `cursor()` so only a chunk or one row of models is in memory, writing each row as you go. Nothing is written to disk and there is no `Content-Length`. Unlike the generator form of `stream()`, `streamDownload()` adds no flushing and no `X-Accel-Buffering` header, so pass the header and flush occasionally if progress should be visible. If the callback throws, Laravel wraps the error in `StreamedResponseException`, which is reported and renders an empty body instead of appending an error page to the half-written CSV.

code

php · 21 lines
php
<?php

use App\Models\Sale;
use Symfony\Component\HttpFoundation\StreamedResponse;

class SalesExportController
{
    public function __invoke(): StreamedResponse
    {
        return response()->streamDownload(function () {
            $out = fopen('php://output', 'w');
            fputcsv($out, ['order_id', 'sold_at', 'region', 'total']);

            Sale::query()->select('id', 'sold_at', 'region', 'total')
                ->orderBy('id')->lazy(2000)
                ->each(fn (Sale $s) => fputcsv($out, [$s->id, $s->sold_at, $s->region, $s->total]));

            fclose($out);
        }, 'sales-2026.csv', ['Content-Type' => 'text/csv', 'X-Accel-Buffering' => 'no']);
    }
}

go deeper

for a junior

Remember the shape: streamDownload with a callback and a filename, fputcsv to php://output, and a query that does not load everything at once.

for a middle

Explain what streamDownload builds (StreamedResponse, attachment disposition, ASCII fallback), why lazy() or cursor() keeps memory flat, and what it does not add automatically.

for a senior

Plan for failure after the 200 is sent: StreamedResponseException reporting, truncation markers, proxy buffering and timeouts, and when to move the export to a job.

for a principal

Set limits on synchronous exports, such as row caps or date ranges, and route larger ones to background generation with a download link.

## The task The finance team wants a **Download all sales** button that returns every order line of the year as `sales-2026.csv`: about a million rows. Two naive approaches fail: - building the CSV in a string, or collecting `Sale::all()`, holds every model in memory and exhausts `memory_limit`; - writing a temporary file first doubles the work and leaves files to clean up, and the user waits for the whole file before the download even starts. `response()->streamDownload()` sends the file while it is being produced. ## What the method builds The signature on `Illuminate\Routing\ResponseFactory` is: ```php public function streamDownload($callback, $name = null, array $headers = [], $disposition = 'attachment') ``` 1. It wraps your callback in a closure that catches any `Throwable` and rethrows it as `Illuminate\Routing\Exceptions\StreamedResponseException`. 2. It creates a Symfony `StreamedResponse` with that closure, status **200** and your headers. 3. If `$name` is given, it sets `Content-Disposition` through `makeDisposition($disposition, $name, $fallback)`, with an ASCII fallback built by `Str::ascii()` and stripped of `%`. Nothing else: no automatic flushing, no `X-Accel-Buffering`, no `Content-Type` unless you pass one. ## Writing the rows Inside the callback you write to PHP's output. The idiomatic way for CSV is a stream handle on `php://output` and `fputcsv()`, which quotes and escapes fields properly: - Write the header row first. - Fetch rows in a memory-bounded way: `lazy(2000)` pulls 2,000 rows per query and yields them one by one; `cursor()` runs one query and hydrates one model at a time. Their trade-offs are part of Eloquent's retrieval methods; either keeps memory flat, while `get()` does not. - Select only the columns you need and order by a stable key. - Optionally call `flush()` every few thousand rows so the browser's download progress moves. ## Behaviour to know | Aspect | What happens | |---|---| | Status | always 200; you cannot change it later | | Size | unknown, so no `Content-Length` and no progress percentage | | Memory | bounded by the query method, not by the number of rows | | Errors mid-way | wrapped in `StreamedResponseException`, reported, and rendered as an empty response rather than an HTML error page | | Headers | pass them in the third argument; the Symfony response has no `withHeaders()` | | Proxy buffering | add `X-Accel-Buffering: no` yourself if chunks should pass through Nginx at once | The error row matters most. The client has already received `200 OK` and part of the file. When a query fails at row 600,000, the exception is reported so you see it in your logs, and the `render()` method of `StreamedResponseException` returns an empty `Response`, so no error page HTML is appended to the CSV. The user still ends up with a truncated file that looks complete, so add a final marker row such as `# end of export, 1,000,000 rows` or a row count the consumer can check. ## A JSON variant If the consumer wants JSON instead of CSV, `response()->streamJson(['sales' => Sale::query()->cursor()])` returns a Symfony `StreamedJsonResponse` that writes the surrounding JSON and iterates the cursor as it encodes, with the encoding options defaulting to Symfony's value of 15. It is not a download by default, so add a `Content-Disposition` header if the browser should save it. ## When streaming is the wrong tool Streaming ties up a PHP worker for as long as the export takes and is bounded by PHP's `max_execution_time` and every proxy timeout on the way. For exports that take minutes, generating the file in a queued job and emailing a link is usually better; that decision is its own question. ## Details that make exports pleasant - **Filename with context:** include the period and a timestamp, such as `sales-2026-q3.csv`, so repeated downloads do not overwrite each other in the user's folder. - **Spreadsheet compatibility:** some spreadsheet programs guess the encoding; writing a UTF-8 byte-order mark as the first bytes helps names with accents display correctly. - **Formula injection:** values that start with `=`, `+`, `-` or `@` can be interpreted as formulas when the file is opened; prefix them with a quote character if the data comes from users. - **Authorization first:** check the user may export this data before returning the response, because after the first byte a failed check can no longer become a 403. ## What interviewers want to hear `streamDownload()` with a filename, `fputcsv()` to `php://output`, a lazy or cursor query instead of `get()`, and awareness that the status is committed at the start so failures produce truncated files.

  • A query fails half-way through the export. What does the user receive, and what do you see?
    The status line and headers were already sent as 200, so the user gets a CSV that simply stops. Laravel wraps the error in `StreamedResponseException`, which is reported through the exception handler and renders an empty response, so no HTML error page lands in the file. Add a trailing marker or row count so the consumer can detect truncation.
  • When would you pick `streamJson()` over `streamDownload()`?
    When the consumer is a program that wants one JSON document, such as `{"sales": [...]}`, rather than a spreadsheet. `streamJson(['sales' => Sale::query()->cursor()])` returns a `StreamedJsonResponse` that encodes the cursor's models as it iterates. It is served inline by default, so add a `Content-Disposition` header if it should download.

saying these in an interview costs you the question

  • streamDownload() writes the file to storage first and then sends it.
  • Using Sale::all() inside the callback is fine because the response is streamed.
  • streamDownload() flushes after every write and disables proxy buffering automatically.
  • If the export fails half-way, Laravel changes the status to 500.
  • The second argument of streamDownload() is the HTTP status code.