Exporting Data to CSV with Laravel Streamed Downloads
A CSV export is one of the most common features a business app grows into. The naive version builds the whole file in memory and then sends it, which is fine for a few hundred rows and disastrous for a few hundred thousand. Laravel's streamed download response lets you write rows to the output as you read them from the database, so memory usage stays flat no matter how large the export gets. In this tutorial we will build an export of customer orders for PHP Architect.
Step 1: The Route and Controller
Start with a route pointing at a controller method:
use App\Http\Controllers\OrderExportController;
Route::get('/orders/export', OrderExportController::class)
->middleware('auth')
->name('orders.export');
We will use a single-action controller. The important piece is response()->streamDownload(), which takes a callback, a filename, and optional headers. Laravel runs the callback while streaming its output straight to the browser.
namespace App\Http\Controllers;
use App\Models\Order;
class OrderExportController extends Controller
{
public function __invoke()
{
$headers = [
'Content-Type' => 'text/csv',
];
return response()->streamDownload(function () {
$handle = fopen('php://output', 'w');
fputcsv($handle, ['ID', 'Customer', 'Total', 'Placed At']);
Order::with('customer')
->lazy()
->each(function (Order $order) use ($handle) {
fputcsv($handle, [
$order->id,
$order->customer->name,
$order->total,
$order->created_at->toDateTimeString(),
]);
});
fclose($handle);
}, 'orders.csv', $headers);
}
}
Step 2: Why lazy() Matters
The lazy() method is what keeps this efficient. Instead of loading every order into a collection, it retrieves them in chunks behind the scenes and yields them one at a time through a LazyCollection. Combined with writing directly to php://output, the server never holds more than a small window of rows in memory.
Contrast that with the version to avoid:
// Loads every order into memory at once. Do not do this for large tables.
$orders = Order::with('customer')->get();
Step 3: Escaping and Encoding
fputcsv() handles quoting and escaping of commas and newlines for you, which is the main reason to use it rather than gluing strings together with commas. If your data contains non-ASCII characters and your users open the file in Excel on Windows, prepend a UTF-8 byte order mark so accented characters render correctly:
$handle = fopen('php://output', 'w');
fwrite($handle, "\xEF\xBB\xBF"); // UTF-8 BOM
Step 4: Handling Truly Huge Exports
Streaming keeps memory flat, but the request still has to finish within your web server's timeout. If an export can run into the millions of rows, move it to a queued job that writes the file to storage and then emails the user a link when it is done. For the common case of thousands to low hundreds of thousands of rows, the streamed response above is simpler and works well.
You now have an export that scales with your database rather than your available memory, using nothing but Laravel's built-in response helpers.
Further Reading
- Laravel documentation, "HTTP Responses", Streamed Downloads (laravel.com/docs/13.x/responses#streamed-downloads)
- Laravel documentation, "Eloquent: Getting Started", Streaming Results Lazily (laravel.com/docs/13.x/eloquent#streaming-results-lazily)
- PHP Manual, "fputcsv" (php.net/manual/en/function.fputcsv.php)