An Excel report at the click of a button: sending XLSX to the browser the right way
“I generate Excel in PHP, the file downloads, but Excel says it is corrupted.” “How do I send an XLSX to the user without saving it to disk?” — two perennial Stack Overflow questions, and both almost always come down to the same thing: the file is generated correctly, but it is delivered to the browser incorrectly.
Everyone knows the scenario: an “Export to Excel” button, a controller builds the report and sends it to the
user. And then come the classic pitfalls: a notice or a forgotten echo got into the output before the binary
data; an included file has a UTF-8 BOM or a space before <?php; the Content-Type stayed text/html; after
the file, the script appended an HTML footer. Any one of these — and Excel reports the file as corrupted: XLSX
is a ZIP, and a ZIP forgives no extra bytes, neither at the start nor at the end.
Let’s go through sending XLSX to the browser with avadim/fast-excel-writer — both in plain PHP and in Laravel and Symfony controllers. Everything below was verified on version 6.17.
composer require avadim/fast-excel-writer
Three ways to get the finished file
The Excel class has three methods that “finalize” the workbook — technically two and a half, but their roles
differ.
save() — to disk
use avadim\FastExcelWriter\Excel;
$excel = Excel::create(['Report']);
$sheet = $excel->sheet();
$sheet->writeRow(['Product', 'Quantity', 'Amount']);
$sheet->writeRow(['Laptop', 3, 2450]);
$excel->save('/var/reports/sales-2026-07.xlsx');
Signature: save(?string $fileName = null, ?bool $overWrite = true): bool. You can set the name in advance with
$excel->setFileName() and call save() without arguments; the second parameter controls overwriting an
existing file.
This is your choice for cron reports, queues, and anything generated in the background: the file lands on disk,
and how to deliver it to the user is a separate question (we will come back to it at the end). One important
detail: a workbook can be saved only once — a second save() throws an exception.
download() — straight to the browser
$excel->download('sales-report.xlsx');
exit;
The download(?string $name = null) method does everything itself: it builds the XLSX into a temporary file,
sends the headers (the correct Content-Type, Content-Disposition: attachment, Cache-Control: max-age=0),
outputs the content with readfile(), and deletes the temporary file.
It builds the headers more carefully than most hand-written solutions: the file name is passed through a
control-character filter (protection against header injection), and for non-ASCII names two variants are sent
at once — an ASCII fallback with the non-ASCII characters replaced by underscores, and filename*=UTF-8''...
per RFC 5987. So download('Umsätze Juli.xlsx') downloads correctly under its real name, and the library appends
the .xlsx extension itself if it is missing.
Always pass a name: without one, the file goes out under the name of the temporary file, something like
xlsx_writer_6aa663cf….tmp.xlsx.
output() — the same thing under another name
output(?string $name = null) is simply an alias of download(): the same code, the same headers. Before
sending headers, both methods check headers_sent() and, if output has already started, write only the file
body into it.
This is where it is easy to be fooled. It seems that inside a StreamedResponse callback the framework has
already sent the headers, so output() will add only the bytes. But Symfony (and therefore Laravel) calls
header() without outputting anything, so inside the callback headers_sent() still returns false. The
library sets its own headers over the framework’s ones, and if there is no name, Content-Disposition ends up
with that very temporary file name. Hence the rule: when calling download() or output() inside a framework,
pass the file name — or, better still, send the file with the framework’s own tools, as in the recipes
below.
The right headers by hand
If you want full control (or are sending a file saved earlier with save()), here is the reference set:
$file = '/var/reports/sales-2026-07.xlsx';
$name = 'Umsätze Juli.xlsx';
// 1. Discard anything that may have ended up in the output buffers
while (ob_get_level() > 0) {
ob_end_clean();
}
// 2. Headers
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
header(
'Content-Disposition: attachment; filename="report.xlsx"; '
. "filename*=UTF-8''" . rawurlencode($name)
);
header('Content-Length: ' . filesize($file));
header('Cache-Control: max-age=0');
// 3. The body — and exit right away so that nothing gets appended after it
readfile($file);
exit;
Why each item matters:
- Content-Type. For XLSX it is
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; theapplication/vnd.ms-excelpopular in old answers is the MIME type of the old binary XLS. - Non-ASCII characters in the name. Only ASCII is safe in the
filenameparameter, so the standard technique is an ASCII fallback infilenameplusfilename*=UTF-8''...with the real name percent-encoded. - Content-Length.
download()does not send it; with it, the browser knows the size in advance and shows honest download progress. - ob_end_clean() in a loop. Buffers can be nested (
output_bufferingin php.ini plus somebody’sob_start()); a single call removes only the top level. - exit after readfile(). Otherwise the framework or a template footer will append HTML to the end of the ZIP.
Where a “corrupted file” comes from: a checklist
If the file still arrives corrupted, check in this order:
- A BOM or a space before
<?phpin any included file — three invisible bytesEF BB BFend up before the ZIP signature. To find the culprit:grep -rl $'^\xEF\xBB\xBF' src/. - A closing
?>followed by a newline — that is why the closing tag is customarily omitted in pure PHP files. - Notices and warnings in the output — with
display_errorson, any warning will ruin the file. - Headers already sent — then
download()silently skips its headers, the file goes out withContent-Type: text/htmland garbage at the start, and you will not see a “headers already sent” error. - Compression on top of binary data —
ob_gzhandlertogether with a manualContent-Lengthproduces a file whose size does not match the header.
Diagnostics: look at the first bytes of the downloaded file (xxd file.xlsx | head -1). A valid XLSX starts
with PK\x03\x04; anything before PK is garbage from the output buffer.
Laravel
The simplest option is avadim/fast-excel-laravel, the Laravel
edition of the library. Its download() returns a regular BinaryFileResponse: Laravel itself sets the headers,
including Content-Length and an ASCII fallback for a non-ASCII name, and the temporary file is deleted after
sending.
namespace App\Http\Controllers;
use App\Models\Sale;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class ReportController extends Controller
{
public function export(): BinaryFileResponse
{
$excel = \Excel::create('Sales');
$sheet = $excel->sheet();
$sheet->writeRow(['Product', 'Quantity', 'Amount']);
foreach (Sale::query()->cursor() as $sale) {
$sheet->writeRow([$sale->product, $sale->qty, $sale->amount]);
}
return $excel->download('sales-report.xlsx');
}
}
Note the cursor(): database rows are read as a stream and go into the writer as a stream, so memory does not
grow with the size of the report. Under the hood it is the same writer, so everything said above about buffers
and the BOM still applies.
If your project uses the bare avadim/fast-excel-writer, streamDownload() will do. Just pass the file name to
output() as well — for the reason explained above:
use avadim\FastExcelWriter\Excel;
$excel = Excel::create(['Sales']);
// ... fill the sheet the same way as above
return response()->streamDownload(
static function () use ($excel) {
$excel->output('sales-report.xlsx'); // without a name, the temporary file's name goes out
},
'sales-report.xlsx',
['Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']
);
An honest caveat: “streaming” applies to generation, not to transfer. A ZIP keeps its table of contents at the
end, so both download() and output() first build the complete XLSX in a temporary file and only then send
it — the browser starts receiving data once the report is fully ready. For reports that take tens of seconds
this matters (see the last section).
Symfony
Since the file is built in full anyway, in Symfony it is convenient to save it to a temporary file and send it
with BinaryFileResponse. Then only the framework controls the headers: setContentDisposition() builds a
correct Content-Disposition with a UTF-8 name and an ASCII fallback, and Content-Length is set
automatically.
namespace App\Controller;
use avadim\FastExcelWriter\Excel;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
class ReportController
{
public function export(): BinaryFileResponse
{
$excel = Excel::create(['Report']);
$sheet = $excel->sheet();
$sheet->writeRow(['Product', 'Quantity', 'Amount']);
// ... fetch from the repository via toIterable()
$file = tempnam(sys_get_temp_dir(), 'xlsx');
$excel->save($file);
$response = new BinaryFileResponse($file);
$response->headers->set(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
);
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
'Umsätze Juli.xlsx',
'report.xlsx' // ASCII fallback
);
$response->deleteFileAfterSend();
return $response;
}
}
The StreamedResponse variant works too, but with the same caveat as in Laravel: inside the callback, call
output() with a file name, otherwise the library replaces your Content-Disposition with its own.
Large reports: don’t hold the HTTP connection open
A report of a couple of thousand rows is generated in a fraction of a second — send it synchronously and keep it simple. But if a report takes a minute or more, the “hold the connection until it is ready” scheme falls apart: nginx and PHP-FPM timeouts, repeated clicks from an impatient user that start parallel generation, and busy workers — several simultaneous exports exhaust the FPM pool, and the whole site goes down.
The right scheme for heavy reports:
- The controller puts a job on the queue (Laravel Queues, Symfony Messenger) and immediately replies “the report is being prepared”.
- A worker generates the file and saves it with
$excel->save($path)— thanks to streaming writes, the worker’s memory does not depend on the number of rows. - The user gets a link and downloads the finished file in a separate fast request —
BinaryFileResponse,Storage::download(), or evenX-Accel-Redirect, handing delivery over to nginx.
As a bonus, the finished file can be sent again, protected with a signed link, and regenerated without the user’s involvement.
Summary
save()— for the background: queues, cron, files that are downloaded separately.download()— for plain PHP: it sets the correct headers itself, including an RFC 5987 name for non-ASCII characters. Always pass a file name.output()is an alias ofdownload(), not “body only”: inside aStreamedResponsethe headers have not gone out yet, and the library sets its own.- In frameworks,
BinaryFileResponseis the most reliable: in Laravel,download()from fast-excel-laravel returns it; in Symfony, build it yourself on top ofsave(). - A “corrupted file” almost always means extra bytes around the ZIP: a BOM, a space before
<?php, a notice in the output, a missingexit. Check the first bytes of the file forPK. - Don’t send long reports over a live connection — use a queue,
save(), and separate delivery.
composer require avadim/fast-excel-writer
Documentation and examples are in the GitHub repository. For Laravel there is a separate edition — avadim/fast-excel-laravel.