Excel-отчёт по кнопке: отдаём XLSX в браузер правильно
«Генерирую 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.
composer require avadim/fast-excel-writer
Три способа получить готовый файл
У класса Excel три метода «финализации» книги — технически два с половиной, но роли у них разные.
save() — на диск
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() — сразу в браузер
$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()), вот эталонный набор:
$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.
Откуда берётся «битый файл»: чек-лист
Если файл всё-таки приходит повреждённым, проверьте по порядку:
- BOM или пробел перед
<?phpв любом подключаемом файле — три невидимых байтаEF BB BFокажутся перед сигнатурой ZIP. Найти виновника:grep -rl $'^\xEF\xBB\xBF' src/. - Закрывающий
?>с переводом строки после него — поэтому в чистых PHP-файлах закрывающий тег принято опускать. - Notice и warning в выводе — при включённом
display_errorsлюбое предупреждение испортит файл. - Заголовки уже отправлены — тогда
download()молча пропускает свои заголовки, файл уйдёт сContent-Type: text/htmlи мусором в начале, а ошибки «headers already sent» вы не увидите. - Сжатие поверх бинарных данных —
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-фолбэк для кириллического имени, а временный файл удалится после отправки.
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() — по причине, разобранной выше:
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 выставится сам.
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, и «ложится» весь сайт.
Правильная схема для тяжёлых отчётов:
- Контроллер ставит задачу в очередь (Laravel Queues, Symfony Messenger) и сразу отвечает «отчёт готовится».
- Воркер генерирует файл и сохраняет его через
$excel->save($path)— благодаря потоковой записи память воркера не зависит от числа строк. - Пользователь получает ссылку и скачивает готовый файл отдельным быстрым запросом —
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()и отдельная раздача.
composer require avadim/fast-excel-writer
Документация и примеры — в репозитории на GitHub. Для Laravel есть отдельная редакция — avadim/fast-excel-laravel.