Excel-отчёт по кнопке: отдаём XLSX в браузер правильно

aVadim • 17.09.2026 10:00 •

«Генерирую Excel в PHP, файл скачивается, но Excel пишет, что он повреждён». «Как отдать XLSX пользователю, не сохраняя его на диск?» — два вечных вопроса со Stack Overflow, и оба почти всегда сводятся к одному и тому же: файл генерируется правильно, а вот отдаётся в браузер — нет.

Сценарий знаком каждому: кнопка «Экспорт в Excel», контроллер строит отчёт и отправляет его пользователю. И тут классические грабли: перед бинарными данными в вывод попал notice или забытый echo; в подключаемом файле — UTF-8 BOM или пробел перед <?php; Content-Type остался text/html; после файла скрипт дописал HTML-футер. Любой из пунктов — и Excel сообщает, что файл повреждён: XLSX — это ZIP, а ZIP не прощает лишних байтов ни в начале, ни в конце.

Разберём, как отдавать XLSX в браузер с помощью avadim/fast-excel-writer — и в чистом PHP, и в контроллерах Laravel и Symfony. Всё ниже проверено на версии 6.17.

bash
composer require avadim/fast-excel-writer

Три способа получить готовый файл

У класса Excel три метода «финализации» книги — технически два с половиной, но роли у них разные.

save() — на диск

php
use avadim\FastExcelWriter\Excel;

$excel = Excel::create(['Report']);
$sheet = $excel->sheet();

$sheet->writeRow(['Товар', 'Количество', 'Сумма']);
$sheet->writeRow(['Ноутбук', 3, 245000]);

$excel->save('/var/reports/sales-2026-07.xlsx');

Сигнатура: save(?string $fileName = null, ?bool $overWrite = true): bool. Имя можно задать заранее через $excel->setFileName() и вызвать save() без аргументов; второй параметр управляет перезаписью существующего файла.

Это ваш выбор для cron-отчётов, очередей и всего, что генерируется в фоне: файл лёг на диск, а как доставить его пользователю — отдельный вопрос (вернёмся к нему в конце). Важная деталь: книга сохраняется только один раз — повторный save() бросит исключение.

download() — сразу в браузер

php
$excel->download('sales-report.xlsx');
exit;

Метод download(?string $name = null) делает всё сам: собирает XLSX во временный файл, отправляет заголовки (правильный Content-Type, Content-Disposition: attachment, Cache-Control: max-age=0), отдаёт содержимое через readfile() и удаляет временный файл.

Заголовки он формирует аккуратнее большинства самописных решений: имя файла прогоняется через фильтр управляющих символов (защита от инъекции в заголовки), а для не-ASCII имён отправляются сразу два варианта — ASCII-фолбэк, где кириллица заменена подчёркиваниями, и filename*=UTF-8''... по RFC 5987. Так что download('отчёт за июль.xlsx') корректно скачается под кириллическим именем, а расширение .xlsx, если его нет, библиотека допишет сама.

Имя передавайте всегда: без него файл уйдёт под именем временного файла вида xlsx_writer_6aa663cf….tmp.xlsx.

output() — то же самое под другим именем

output(?string $name = null) — просто алиас download(): тот же код, те же заголовки. Оба метода перед отправкой заголовков проверяют headers_sent() и, если вывод уже начался, пишут в него только тело файла.

Здесь легко обмануться. Кажется, что внутри callback'а StreamedResponse заголовки уже отправил фреймворк и output() добавит только байты. Но Symfony (а значит, и Laravel) вызывает header(), ничего не выводя, поэтому внутри callback'а headers_sent() ещё возвращает false. Библиотека выставит свои заголовки поверх заголовков фреймворка, и если имени нет, в Content-Disposition окажется то самое имя временного файла. Отсюда правило: вызывая download() или output() внутри фреймворка, передавайте имя файла — а лучше отдавайте файл средствами самого фреймворка, как в рецептах ниже.

Правильные заголовки вручную

Если вы хотите полного контроля (или отдаёте файл, сохранённый ранее через save()), вот эталонный набор:

php
$file = '/var/reports/sales-2026-07.xlsx';
$name = 'Отчёт по продажам.xlsx';

// 1. Вычищаем всё, что могло попасть в буферы вывода
while (ob_get_level() > 0) {
    ob_end_clean();
}

// 2. Заголовки
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. Тело — и сразу выходим, чтобы ничего не дописалось следом
readfile($file);
exit;

Почему каждый пункт важен:

  • Content-Type. Для XLSX это application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; популярный в старых ответах application/vnd.ms-excel — MIME-тип старого бинарного XLS.
  • Кириллица в имени. В параметре filename безопасен только ASCII, поэтому приём стандартный: латинский фолбэк в filename плюс filename*=UTF-8''... с процентно-закодированным настоящим именем.
  • Content-Length. download() его не отправляет; с ним браузер знает размер заранее и показывает честный прогресс загрузки.
  • ob_end_clean() в цикле. Буферы бывают вложенными (output_buffering в php.ini + чей-то ob_start()), одиночный вызов снимает только верхний уровень.
  • exit после readfile(). Иначе фреймворк или футер шаблона допишут HTML в конец ZIP.

Откуда берётся «битый файл»: чек-лист

Если файл всё-таки приходит повреждённым, проверьте по порядку:

  1. BOM или пробел перед <?php в любом подключаемом файле — три невидимых байта EF BB BF окажутся перед сигнатурой ZIP. Найти виновника: grep -rl $'^\xEF\xBB\xBF' src/.
  2. Закрывающий ?> с переводом строки после него — поэтому в чистых PHP-файлах закрывающий тег принято опускать.
  3. Notice и warning в выводе — при включённом display_errors любое предупреждение испортит файл.
  4. Заголовки уже отправлены — тогда download() молча пропускает свои заголовки, файл уйдёт с Content-Type: text/html и мусором в начале, а ошибки «headers already sent» вы не увидите.
  5. Сжатие поверх бинарных данных — ob_gzhandler вместе с ручным Content-Length даёт файл, размер которого не совпадает с заголовком.

Диагностика: посмотрите первые байты скачанного файла (xxd file.xlsx | head -1). Корректный XLSX начинается с PK\x03\x04; всё, что перед PK, — мусор из буфера вывода.

Laravel

Проще всего взять avadim/fast-excel-laravel — редакцию библиотеки для Laravel. Её download() возвращает обычный BinaryFileResponse: заголовки выставит сам Laravel, включая Content-Length и ASCII-фолбэк для кириллического имени, а временный файл удалится после отправки.

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(['Товар', 'Количество', 'Сумма']);
        foreach (Sale::query()->cursor() as $sale) {
            $sheet->writeRow([$sale->product, $sale->qty, $sale->amount]);
        }

        return $excel->download('sales-report.xlsx');
    }
}

Обратите внимание на cursor() — строки БД читаются потоково и так же потоково уходят в writer, поэтому память не растёт с размером отчёта. Под капотом тот же writer, так что всё сказанное выше про буферы и BOM остаётся в силе.

Если в проекте «голый» avadim/fast-excel-writer, подойдёт streamDownload(). Только имя файла передайте и в output() — по причине, разобранной выше:

php
use avadim\FastExcelWriter\Excel;

$excel = Excel::create(['Sales']);
// ... заполняем лист так же, как выше

return response()->streamDownload(
    static function () use ($excel) {
        $excel->output('sales-report.xlsx'); // без имени уйдёт имя временного файла
    },
    'sales-report.xlsx',
    ['Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']
);

Честная оговорка: «потоково» относится к генерации, а не к передаче. У ZIP оглавление лежит в конце, поэтому и download(), и output() сначала полностью собирают XLSX во временный файл и лишь затем отдают его — браузер начнёт получать данные, когда отчёт готов целиком. Для отчётов на десятки секунд это важно (см. последний раздел).

Symfony

Раз файл всё равно собирается целиком, в Symfony его удобно сохранить во временный файл и отдать через BinaryFileResponse. Заголовками тогда управляет только фреймворк: setContentDisposition() корректно соберёт Content-Disposition с UTF-8 именем и ASCII-фолбэком, а Content-Length выставится сам.

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(['Товар', 'Количество', 'Сумма']);
        // ... выборка из репозитория через 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,
            'отчёт-июль.xlsx',
            'report.xlsx' // ASCII-фолбэк
        );
        $response->deleteFileAfterSend();

        return $response;
    }
}

Вариант со StreamedResponse тоже рабочий, но с той же оговоркой, что и в Laravel: внутри callback'а вызывайте output() с именем файла, иначе библиотека заменит ваш Content-Disposition своим.

Большие отчёты: не держите HTTP-соединение

Отчёт на пару тысяч строк генерируется за доли секунды — отдавайте его синхронно и не усложняйте. Но если отчёт строится минуту и дольше, схема «держим соединение до готовности» разваливается: таймауты nginx и PHP-FPM, повторные клики нетерпеливого пользователя, запускающие параллельную генерацию, и занятые воркеры — несколько одновременных экспортов исчерпывают пул FPM, и «ложится» весь сайт.

Правильная схема для тяжёлых отчётов:

  1. Контроллер ставит задачу в очередь (Laravel Queues, Symfony Messenger) и сразу отвечает «отчёт готовится».
  2. Воркер генерирует файл и сохраняет его через $excel->save($path) — благодаря потоковой записи память воркера не зависит от числа строк.
  3. Пользователь получает ссылку и скачивает готовый файл отдельным быстрым запросом — BinaryFileResponse, Storage::download() или вовсе X-Accel-Redirect, отдав раздачу nginx.

Бонусом — готовый файл можно отдать повторно, защитить подписанной ссылкой и перегенерировать без участия пользователя.

Итог

  • save() — для фона: очереди, cron, файлы, которые скачиваются отдельно.
  • download() — для «чистого» PHP: сам ставит корректные заголовки, включая имя по RFC 5987 для кириллицы. Имя файла передавайте всегда.
  • output() — алиас download(), а не «только тело»: внутри StreamedResponse заголовки ещё не ушли, и библиотека выставит свои.
  • Во фреймворках надёжнее всего BinaryFileResponse: в Laravel его возвращает download() из fast-excel-laravel, в Symfony — соберите его сами поверх save().
  • «Битый файл» — почти всегда лишние байты вокруг ZIP: BOM, пробел перед <?php, notice в выводе, отсутствие exit. Проверяйте первые байты файла на PK.
  • Долгие отчёты не отдавайте по живому соединению — очередь, save() и отдельная раздача.
bash
composer require avadim/fast-excel-writer

Документация и примеры — в репозитории на GitHub. Для Laravel есть отдельная редакция — avadim/fast-excel-laravel.

Комментарии (0)