In Laravel, how do you use response()->streamDownload() to export a million-row sales report as CSV without building it in memory?
answer
- write rows while the response is sent
- streamDownload($callback, $name, $headers, $disposition)
- fputcsv() to php://output
- lazy() or cursor() instead of get()
- exceptions wrapped in StreamedResponseException
basics
~20 sresponse()->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
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
Remember the shape: streamDownload with a callback and a filename, fputcsv to php://output, and a query that does not load everything at once.
Explain what streamDownload builds (StreamedResponse, attachment disposition, ASCII fallback), why lazy() or cursor() keeps memory flat, and what it does not add automatically.
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.
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.