Streaming a Filtered Orders CSV Export in Laravel with StreamedResponse

Shared by codesnips Jul 2026
3 tabs
<?php

namespace App\Exports;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;

class OrderExportFilter
{
    private const ALLOWED_STATUSES = ['pending', 'paid', 'shipped', 'cancelled', 'refunded'];

    public function __construct(private array $filters)
    {
    }

    public function applyTo(Builder $query): Builder
    {
        if (!empty($this->filters['status']) && in_array($this->filters['status'], self::ALLOWED_STATUSES, true)) {
            $query->where('status', $this->filters['status']);
        }

        if (!empty($this->filters['customer_id'])) {
            $query->where('customer_id', (int) $this->filters['customer_id']);
        }

        if (!empty($this->filters['from']) && !empty($this->filters['to'])) {
            $from = Carbon::parse($this->filters['from'])->startOfDay();
            $to = Carbon::parse($this->filters['to'])->endOfDay();
            $query->whereBetween('created_at', [$from, $to]);
        }

        return $query->orderBy('id');
    }
}
3 files · php Explain with highlit

This snippet shows how a filtered orders report is streamed to the browser as a CSV file in Laravel without buffering the entire result set in memory. The naive approach — building a giant string or an array of rows and returning it — collapses under large tables because it holds every row in RAM at once and delays the first byte until the whole file is assembled. A streamed response instead writes rows to the output buffer incrementally, so PHP's memory footprint stays flat regardless of how many orders match the filter.

The OrderExportFilter class isolates the query-building logic from the transport concern. It accepts the validated request input and, in applyTo, layers optional where and whereBetween clauses onto an Eloquent builder. Keeping this in its own object means the same filter can be reused by the export, by an HTML index page, or by a count endpoint, and it keeps the controller thin. Note that only whitelisted status values are trusted and dates are parsed defensively, which matters because the filter values originate from user input.

OrdersExporter is where the streaming actually happens. The stream method returns a Symfony\Component\HttpFoundation\StreamedResponse, whose callback runs after headers are sent. Inside the callback it opens php://output as a file handle and uses fputcsv, which correctly escapes commas, quotes, and newlines in field values — a common source of corrupt CSVs when people concatenate strings by hand. The header row is written first, then the query is walked with chunkById.

Using chunkById rather than chunk or get is the key reliability detail. get would load everything; plain chunk uses OFFSET pagination, which can skip or duplicate rows if data changes mid-export. chunkById paginates on the primary key, so it is stable under concurrent writes and keeps each batch small (500 rows here). After each chunk the code calls flush so bytes reach the client promptly instead of waiting for the whole run.

The Content-Disposition header with attachment triggers a download dialog, and Content-Type: text/csv labels the payload. Because the response is streamed, no Content-Length is sent and the browser shows an indeterminate progress bar — an acceptable trade-off for unbounded exports.

Finally, OrdersExportController ties it together. The download action validates incoming filters, constructs an OrderExportFilter, applies it to a base Order::query(), and hands the resulting builder to the exporter. A timestamped filename is generated so repeated exports do not overwrite one another in the user's downloads folder. One pitfall worth calling out: streamed responses bypass most middleware that inspects the body, and any exception thrown inside the callback happens after headers are flushed, so it cannot change the status code — validation must occur before streaming begins, exactly as it does here.

Share this code

Here's the card — post it anywhere.

Streaming a Filtered Orders CSV Export in Laravel with StreamedResponse — share card
Link copied