The ticket said "Export button returns a blank page." It worked on staging. It worked for most customers. It failed only for the accounts that used the system the most, which is a polite way of saying it failed for the people who paid the bills.
The cause was not exotic. The export action loaded every matching order into memory, built one giant string, and returned it. With a few thousand rows that is fine. With a few hundred thousand rows, PHP hit memory_limit, the worker died, and the browser got nothing useful back.
This post is the fix I wish I had written the first time: stream the file row by row, keep memory flat no matter how many rows there are, and avoid the small traps that show up once the export is "fast enough."
The problem: exports grow, memory limits don't
A typical first version looks like this:
public function export(Request $request) { $orders = Order::with('customer') ->whereBetween('ordered_at', [$request->from, $request->to]) ->get(); $csv = "Order No,Date,Customer,Total\n"; foreach ($orders as $order) { $csv .= "{$order->number},{$order->ordered_at},{$order->customer->name},{$order->total}\n"; } return response($csv) ->header('Content-Type', 'text/csv') ->header('Content-Disposition', 'attachment; filename="orders.csv"'); } There are three separate memory costs here, and they stack:
-
get()hydrates every Eloquent model (plus every eager-loaded customer) at once. - The
$csvstring grows to the size of the whole file. - The response holds that string again before it is sent.
It also has correctness bugs: a customer name with a comma breaks the columns, and nothing is escaped. But memory is what takes the server down first.
The Laravel docs are direct about the first point: loading tens of thousands of records with all or get can run your application out of memory, and chunk, lazy, and cursor exist for exactly this reason.
Step 1: stream the response instead of building a string
Laravel's streamDownload takes a callback, a filename, and optional headers. Whatever the callback writes to the output is sent to the client as it is produced. Nothing is written to disk and you never hold the full file in memory.
Writing to php://output with fputcsv gives you proper quoting for free:
return response()->streamDownload(function () { $out = fopen('php://output', 'w'); fputcsv($out, ['Order No', 'Date', 'Customer', 'Total'], ',', '"', ''); // rows go here fclose($out); }, 'orders.csv', ['Content-Type' => 'text/csv; charset=UTF-8']); Notice the explicit ',', '"', '' arguments. As of PHP 8.4, relying on the default value of the escape parameter of fputcsv is deprecated. The PHP manual also recommends an empty string for escape, because the legacy backslash escaping can produce CSV that is not RFC 4180 compliant. Passing all three explicitly keeps your logs clean today and your files portable tomorrow.
Step 2: read the database in chunks, not all at once
Streaming the output only helps if the input is streamed too. Eloquent gives you a few options:
-
chunk(200, fn ($models) => ...)runs one query per chunk and hands you a collection. -
lazy()also queries in chunks, but returns a flatLazyCollection, so you can write a simple loop. -
lazyById()does the same, but pages by primary key (where id > last_id) instead ofOFFSET. -
cursor()runs a single query and hydrates one model at a time.
For exports I default to lazyById():
- It pages by key, so it stays fast deep into a large table, and it doesn't skip or repeat rows if records are updated while you iterate. The docs recommend the
ByIdvariants whenever the filtered column might change during iteration. - Unlike
cursor(), it supports eager loading. The docs note thatcursor()cannot eager load relationships and that PDO may still buffer all raw results internally, so a very large cursor can still run out of memory.
Here is the full controller:
<?php namespace App\Http\Controllers; use App\Models\Order; use App\Support\Csv; use Illuminate\Http\Request; use Illuminate\Support\Carbon; use Symfony\Component\HttpFoundation\StreamedResponse; class OrderExportController extends Controller { public function __invoke(Request $request): StreamedResponse { $validated = $request->validate([ 'from' => ['required', 'date'], 'to' => ['required', 'date', 'after_or_equal:from'], ]); $from = Carbon::parse($validated['from'])->startOfDay(); $to = Carbon::parse($validated['to'])->endOfDay(); $filename = 'orders-'.$from->format('Ymd').'-'.$to->format('Ymd').'.csv'; return response()->streamDownload(function () use ($from, $to) { $out = fopen('php://output', 'w'); // UTF-8 BOM so spreadsheet apps detect the encoding correctly. fwrite($out, "\xEF\xBB\xBF"); Csv::writeRow($out, ['Order No', 'Date', 'Customer', 'Currency', 'Total']); Order::query() ->select(['id', 'number', 'ordered_at', 'customer_id', 'currency', 'total']) ->with('customer:id,name') ->whereBetween('ordered_at', [$from, $to]) ->lazyById(1000) ->each(function (Order $order) use ($out) { Csv::writeRow($out, [ $order->number, $order->ordered_at->toDateString(), $order->customer?->name, $order->currency, $order->total, ]); }); fclose($out); }, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']); } } A few details worth calling out:
-
select([...])keeps each hydrated model small. Exports rarely need every column. -
idmust be in the select list, becauselazyByIdpages on it. -
customer:id,namelimits the eager load to the two columns you need. The foreign key on the parent (customer_id) must also be selected, or the relationship comes back empty. - The date range uses
startOfDay()andendOfDay(). Without that, "to 2026-11-08" quietly means "to midnight at the start of the 8th," and the last day is missing from every export.
Step 3: escape cells that spreadsheets treat as formulas
Once the export works, someone opens it in a spreadsheet. If any cell starts with =, +, - or @, the spreadsheet may treat it as a formula. A customer name like =HYPERLINK(...) turns your export into an attack surface. OWASP calls this CSV injection (formula injection), and the usual mitigation is to prefix those cells with a single quote.
A tiny helper keeps the controller readable:
<?php namespace App\Support; final class Csv { private const FORMULA_PREFIXES = ['=', '+', '-', '@', "\t", "\r"]; /** @param resource $stream */ public static function writeRow($stream, array $fields): void { fputcsv($stream, array_map(self::safe(...), $fields), ',', '"', ''); } public static function safe(mixed $value): mixed { if (is_string($value) && $value !== '' && in_array($value[0], self::FORMULA_PREFIXES, true)) { return "'".$value; } return $value; } } Numbers are left alone, so a negative total like -12.50 stored as a decimal still exports as a number. If your totals are cast to strings, decide explicitly whether a leading minus should be escaped for your users' spreadsheet tools.
Step 4: know when to stop streaming and queue instead
Streaming keeps memory flat, but the request is still a request. It is still subject to PHP's max_execution_time, your web server's timeouts, and the user's patience. In my experience the rule of thumb is simple: if an export regularly takes longer than a user will wait with the tab open, move it to a queued job, write the file to storage, and notify the user when it is ready.
The same streaming ideas apply inside the job. Write to a temporary stream and hand the resource to the filesystem, which Laravel passes through to Flysystem's stream support:
$stream = fopen('php://temp', 'w+'); Csv::writeRow($stream, ['Order No', 'Date', 'Customer', 'Currency', 'Total']); Order::query() ->select(['id', 'number', 'ordered_at', 'customer_id', 'currency', 'total']) ->with('customer:id,name') ->whereBetween('ordered_at', [$this->from, $this->to]) ->lazyById(1000) ->each(fn (Order $order) => Csv::writeRow($stream, [ $order->number, $order->ordered_at->toDateString(), $order->customer?->name, $order->currency, $order->total, ])); rewind($stream); Storage::disk('local')->put($this->path, $stream); fclose($stream); php://temp keeps small files in memory and spills larger ones to a temporary file automatically, so memory stays bounded here too.
Common pitfalls
Grouping your own orWhere conditions. lazyById and chunkById add their own where id > ? clause. If your filter has an orWhere, wrap it in a closure, or the paging condition will only apply to one side of the OR. The Laravel docs call this out explicitly.
Debug tools that collect every query. A debug toolbar or query logger that records every executed query will happily grow with your export. Make sure such tools are off for export routes in production.
Accessors and appended attributes. If the model has expensive accessors or $appends, building each row can trigger extra work or extra queries per row. Read the raw attributes you need and format them in the export.
Output buffering in front of PHP. Some proxy setups buffer the whole response before sending it. For downloads that is usually fine, but if users see nothing for a long time, check your proxy's buffering settings before blaming PHP.
Testing only with small data. The original bug survived because every test and every staging account had a few hundred orders. Seed one realistic big account in staging and run your exports against it before release.
Summary
- Never build a large export in a string or a full collection.
- Use
response()->streamDownload()withfputcsvonphp://output. - Read with
lazyById(), select only the columns you need, and eager load narrowly. - Pass
fputcsv's separator, enclosure and escape explicitly; PHP 8.4 deprecates relying on the escape default. - Escape cells that start with formula characters.
- When exports take longer than a user will wait, queue them and write to storage with a stream.
The fix took an afternoon. Finding it took longer, because the bug only lived where the data was big. That is the real lesson: performance limits are part of the feature, so test with the data your heaviest users actually have.
What is the largest export your app serves today, and do you know how much memory it uses at peak?