An Excel report at the click of a button: sending XLSX to the browser the right way

aVadim • 17.09.2026 10:00 •

“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.

bash
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

php
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

php
$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:

php
$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; the application/vnd.ms-excel popular in old answers is the MIME type of the old binary XLS.
  • Non-ASCII characters in the name. Only ASCII is safe in the filename parameter, so the standard technique is an ASCII fallback in filename plus filename*=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_buffering in php.ini plus somebody’s ob_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:

  1. A BOM or a space before <?php in any included file — three invisible bytes EF BB BF end up before the ZIP signature. To find the culprit: grep -rl $'^\xEF\xBB\xBF' src/.
  2. A closing ?> followed by a newline — that is why the closing tag is customarily omitted in pure PHP files.
  3. Notices and warnings in the output — with display_errors on, any warning will ruin the file.
  4. Headers already sent — then download() silently skips its headers, the file goes out with Content-Type: text/html and garbage at the start, and you will not see a “headers already sent” error.
  5. Compression on top of binary data — ob_gzhandler together with a manual Content-Length produces 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.

php
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:

php
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.

php
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:

  1. The controller puts a job on the queue (Laravel Queues, Symfony Messenger) and immediately replies “the report is being prepared”.
  2. 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.
  3. The user gets a link and downloads the finished file in a separate fast request — BinaryFileResponse, Storage::download(), or even X-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 of download(), not “body only”: inside a StreamedResponse the headers have not gone out yet, and the library sets its own.
  • In frameworks, BinaryFileResponse is the most reliable: in Laravel, download() from fast-excel-laravel returns it; in Symfony, build it yourself on top of save().
  • A “corrupted file” almost always means extra bytes around the ZIP: a BOM, a space before <?php, a notice in the output, a missing exit. Check the first bytes of the file for PK.
  • Don’t send long reports over a live connection — use a queue, save(), and separate delivery.
bash
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.

Comments (0)