A client had a "Download CSV" button on a Livewire invoices table. To serve it, the team had built a signed route, a dedicated controller, a temp-file writer and a scheduled job to clean up the leftovers. None of it was necessary. A Livewire file download is a return statement inside the action you already have.
Add a wire:click action to the download button#
Livewire treats an action's return value the same way Laravel treats a controller's. If the action returns a download response, Livewire sends the file to the browser instead of a normal HTML patch — so the method name in wire:click is the whole endpoint. The example below is a Livewire 4 single-file component; if you are still on the class-based format from v3 the method body is identical, only the file location differs, which the Livewire 3 to 4 migration guide covers in detail.
<?php // resources/views/components/⚡export-invoices.blade.php
use App\Models\Invoice;
use Livewire\Component;
new class extends Component {
public function export()
{
// Whatever this returns is what the browser receives.
return response()->download(storage_path('app/exports/invoices.csv'));
}
};
?>
<div>
<button type="button" wire:click="export">
Download CSV
</button>
{{-- The request is a normal Livewire round trip, so wire:loading works --}}
<span wire:loading wire:target="export">Preparing your file…</span>
</div>
Delete use WithFileDownloads; if you copied it from an older tutorial. That trait was a Livewire 2 requirement and no longer exists — download support has been in core since v3.
Return a Livewire file download for a file already on disk#
When the file exists, hand Laravel the path and let it build the response. response()->download() expects an absolute local path and takes an optional display filename and header array. Storage::disk()->download() does the same thing against any configured disk, including S3, which saves you streaming the object into a temp file yourself.
use Illuminate\Support\Facades\Storage;
public function download()
{
// Local path, custom filename shown to the user...
return response()->download(
$this->invoice->file_path,
"invoice-{$this->invoice->reference}.pdf",
);
}
public function downloadFromS3()
{
// Any disk in config/filesystems.php, remote included...
return Storage::disk('invoices')->download(
$this->invoice->storage_path,
'invoice.pdf',
);
}
Symfony's HttpFoundation handles the response and requires an ASCII filename, so sanitise anything built from user input — a customer name with an accent in it will throw. This is the mirror image of the upload path; if you are also accepting files in the same component, pushing Livewire temporary uploads straight to S3 keeps both directions off your app server's disk.
Generate a CSV on the fly with streamDownload#
For a report that does not exist yet, skip the temp file entirely. response()->streamDownload() takes a callback, a filename and optional headers, and anything the callback echoes becomes the body of the file. Open php://output as a stream and fputcsv() straight into it.
public function export()
{
$filename = 'invoices-'.now()->format('Y-m-d').'.csv';
return response()->streamDownload(function () {
// php://output writes into the response body, not to disk...
$handle = fopen('php://output', 'w');
fputcsv($handle, ['Reference', 'Customer', 'Total', 'Issued']);
foreach (Invoice::with('customer')->get() as $invoice) {
fputcsv($handle, [
$invoice->reference,
$invoice->customer->name,
$invoice->total->formatted(),
$invoice->issued_at->toDateString(),
]);
}
fclose($handle);
}, $filename, ['Content-Type' => 'text/csv']);
}
Do not bother calling ob_flush() or flush() in here. In a plain Laravel route those push bytes to the client as they are produced; inside Livewire the response is collected in full before it goes anywhere, so they do nothing but add noise.
Stream large exports in chunks with LazyCollection#
The version above still calls ->get(), which hydrates every invoice before the first row is written. lazyById() walks the table in batches ordered by primary key and returns a LazyCollection, so only one chunk of models is resident at a time and the CSV is written incrementally.
public function export()
{
return response()->streamDownload(function () {
$handle = fopen('php://output', 'w');
fputcsv($handle, ['Reference', 'Customer', 'Total', 'Issued']);
Invoice::query()
->with('customer')
->lazyById(1000) // Keyset pagination, not OFFSET — stable under writes...
->each(function (Invoice $invoice) use ($handle) {
fputcsv($handle, [
$invoice->reference,
$invoice->customer->name,
$invoice->total->formatted(),
$invoice->issued_at->toDateString(),
]);
});
fclose($handle);
}, 'invoices.csv');
}
lazyById() beats chunk() here because it pages on the primary key rather than OFFSET, so rows inserted mid-export cannot cause skips or duplicates. The same pattern applies to any large tabular job — processing CSVs with lazy collections goes through the read side of it.
Authorize the download before you return it#
Making the action the endpoint means the action inherits the endpoint's responsibilities. Every argument passed through wire:click and every public property is mutable in the browser, so an unguarded export($invoiceId) will happily serve another tenant's data to anyone who edits the DOM.
use App\Models\Invoice;
use Livewire\Attributes\Locked;
new class extends Component {
// #[Locked] rejects client-side tampering with the ID...
#[Locked]
public int $invoiceId;
public function download()
{
$invoice = Invoice::findOrFail($this->invoiceId);
// Throws AuthorizationException if the policy denies it...
$this->authorize('view', $invoice);
return Storage::disk('invoices')->download($invoice->storage_path);
}
};
Typing the property as public Invoice $invoice is stronger still — Livewire re-resolves models from the database on each request, so the ID cannot be swapped at all. #[Locked] is the fallback when a model property is impractical; the details are in locked properties and preventing client-side tampering.
Test the Livewire file download with assertFileDownloaded#
Livewire ships assertions for exactly this, so there is no reason to leave downloads untested. assertFileDownloaded() checks a download effect came back with the given filename, and assertNoFileDownloaded() proves your authorization guard actually bites.
use App\Models\Invoice;
use App\Models\User;
use Livewire\Livewire;
it('downloads the invoice for its owner', function () {
$invoice = Invoice::factory()->create();
Livewire::actingAs($invoice->customer->user)
->test('export-invoices', ['invoiceId' => $invoice->id])
->call('download')
->assertFileDownloaded('invoice.pdf');
});
it('does not download the invoice for a stranger', function () {
$invoice = Invoice::factory()->create();
Livewire::actingAs(User::factory()->create())
->test('export-invoices', ['invoiceId' => $invoice->id])
->call('download')
->assertNoFileDownloaded();
});
Note the second test asserts on the absence of a download rather than on an exception — a policy failure surfaces as an AuthorizationException in real traffic, and asserting the file never left is the check that matters. More patterns for this in testing Livewire components with Pest.
Know when to move the export to a queue#
Here is the trade-off the docs are refreshingly blunt about: Livewire downloads are not really streamed. The response body is collected in full, base64-encoded, sent as a JSON effect and decoded back to binary by JavaScript in the browser. Chunking with lazyById() keeps your PHP process flat while it generates, but the finished file still lands in one buffer and gains about a third in size on the wire.
In practice that holds up fine to roughly 20-30 MB. Past that, stop returning the file and start returning a message: dispatch a job, write the export to S3, and notify the user with a temporary signed URL when it lands. Scaling Laravel queues in production covers the worker side of that handoff.
FAQ#
Can a Livewire action return a file download?
Yes. Any Livewire action can return a Laravel download response and Livewire will trigger the browser download for you. There is no route, controller or signed URL to set up, and no trait to import — support has been in Livewire core since v3, so the WithFileDownloads trait from Livewire 2 is gone.
How do I use streamDownload in Livewire?
Return response()->streamDownload($callback, $filename, $headers) from your action. Everything the callback echoes becomes the file's contents, so open php://output as a stream and write to it directly. Despite the name it is not a true stream inside Livewire — the output is buffered in full before the browser sees any of it.
How do I export a CSV from a Livewire component?
Combine streamDownload() with fputcsv() writing to php://output. Write your header row first, then iterate your query and write one row per record. Use lazyById() rather than get() or all() so you never hold the entire result set in memory at once.
Why does my Livewire download not start?
The most common causes are the action not returning the response at all — an easy slip when the download call sits inside a closure or a conditional branch — and an exception being thrown before the return, which Livewire surfaces as a failed request rather than a file. Open the network tab, find the Livewire update request and look for a download entry in the response's effects. If it is missing, the problem is server side; if it is present but nothing happens, suspect a browser popup or download blocker.
How do I stream a large file download in Livewire without running out of memory?
Use streamDownload() with lazyById() so the export is generated in chunks instead of being materialised as one array. That controls memory while the file is being built, but it does not remove Livewire's ceiling — the completed file is still base64-encoded into a single response. For genuinely large exports, queue a job that writes the file to object storage and email the user a temporary signed URL instead.