Генерация PDF и Excel в Laravel: ошибки, которые всплывают в проде
Отчёт на 50 тысяч строк валит воркер по памяти, кириллица в PDF превращается в квадраты, а файл скачивается пустым. Разбираем, где ломается генерация документов в Laravel и как этого избежать.

Локально всё работает: клиент нажимает «Экспорт», через секунду скачивается аккуратный Excel. На проде тот же экспорт по большому периоду роняет очередь с Allowed memory size of 536870912 bytes exhausted, а PDF с русскими фамилиями открывается набором прямоугольников. Обе проблемы — не в Laravel, а в том, как обычно подключают barryvdh/laravel-dompdf и maatwebsite/excel по первому попавшемуся туториалу.
Разберём типичные приёмы, у каждого — цена: что выигрываете и чем платите. Ориентируюсь на Laravel 11.x, maatwebsite/excel 3.1.x (движок PhpSpreadsheet), barryvdh/laravel-dompdf 3.x. Инвалидация кеша готовых файлов и подпись документов — отдельные темы, здесь только сама генерация.
Excel: почему коллекция в памяти — мина замедленного действия
Самый частый способ из документации maatwebsite/excel — экспорт из коллекции:
use Maatwebsite\Excel\Concerns\FromCollection;
class OrdersExport implements FromCollection
{
public function collection()
{
// Загружает все заказы в память разом
return Order::with('items')->get();
}
}На тысяче строк это работает. На пятидесяти тысячах с жадной загрузкой связей with('items') процесс съедает сотни мегабайт: PhpSpreadsheet держит в памяти и модели Eloquent, и объекты ячеек. Дальше — либо fatal по памяти, либо воркер очереди убивается супервизором по таймауту.
Приём 1: FromQuery вместо FromCollection
Отдавайте не результат, а сам билдер запроса. Пакет сам разобьёт выборку на чанки:
use Maatwebsite\Excel\Concerns\FromQuery;
use Maatwebsite\Excel\Concerns\WithChunkReading;
use Maatwebsite\Excel\Concerns\WithHeadings;
class OrdersExport implements FromQuery, WithChunkReading, WithHeadings
{
public function __construct(private int $shopId) {}
public function query()
{
// Возвращаем билдер, а не ->get()
return Order::query()->where('shop_id', $this->shopId);
}
public function chunkSize(): int
{
return 1000; // сколько строк держим в памяти за раз
}
public function headings(): array
{
return ['ID', 'Сумма', 'Дата'];
}
}Что выигрываете: постоянное потребление памяти вместо линейного роста. Чем платите: query() обязана возвращать билдер без ->get(), и упорядоченный запрос лучше задавать явным orderBy по индексированному полю — иначе чанки на больших смещениях замедляются.
Приём 2: экспорт в очереди
Даже с чанками генерация файла на десятки тысяч строк — это секунды, которые нельзя держать в HTTP-запросе: nginx отдаст 504. Выносите в очередь через ShouldQueue и сохраняйте на диск, а пользователю отдавайте ссылку, когда файл готов.
use Maatwebsite\Excel\Facades\Excel;
// Кладём задачу в очередь, файл появится в storage/app/exports
(new OrdersExport($shopId))
->queue('exports/orders_' . $shopId . '.xlsx');Если экспорт занимает больше пары секунд, он не должен жить в контроллере. Пользователю нужен не мгновенный файл, а гарантия, что файл будет — и уведомление, когда он готов.
Форматы: xlsx против csv
Для миллиона строк даже чанки на xlsx упрутся в overhead формата. CSV пишется потоково и почти не держит память, но теряет форматирование и типы. Выбирайте по объёму и требованиям к виду файла.
| Формат / приём | Память | Когда брать |
|---|---|---|
| FromCollection, xlsx | Растёт линейно | До нескольких тысяч строк, простые отчёты |
| FromQuery + WithChunkReading, xlsx | Постоянная | Десятки тысяч строк, нужно форматирование |
| CSV потоком | Минимальная | Сотни тысяч строк, форматирование не нужно |
PDF: кириллица, шрифты и вёрстка
DomPDF по умолчанию рендерит встроенными шрифтами, которые не содержат кириллицы. Результат — квадраты или пустые места вместо русского текста. Это не баг Laravel, а ограничение движка: нужен шрифт с кириллическими глифами, явно подключённый.
Приём 3: подключить шрифт с кириллицей
Возьмите TTF со свободной лицензией (например, DejaVu Sans идёт в комплекте DomPDF и кириллицу поддерживает) и задайте его в CSS шаблона, а не полагайтесь на дефолт:
body {
font-family: 'DejaVu Sans', sans-serif;
}Если нужен свой TTF, DomPDF умеет регистрировать шрифты через конфиг пакета. Точный способ регистрации и путь к каталогу шрифтов сверяйте в README barryvdh/laravel-dompdf вашей версии — API конфигурации там менялось между мажорными релизами, поэтому наизусть имена ключей приводить не буду.
Приём 4: не гонять весь HTML фронтенда в PDF
DomPDF реализует ограниченное подмножество CSS. Flexbox и grid он не поддерживает — вёрстка на них рассыпается. Приём, который экономит часы отладки: делать отдельный blade-шаблон под PDF на таблицах и инлайновых стилях, а не пытаться скормить движку тот же макет, что видит браузер.
- Раскладка — через
<table>, а не flex/grid; - Стили — инлайн или в одном
<style>в шапке, внешние CSS-файлы подтягиваются ненадёжно; - Картинки — по абсолютному пути файловой системы или base64, относительные URL часто не резолвятся;
- Разрыв страниц — через CSS-свойство
page-break-after.
Когда DomPDF не тянет
Если нужен пиксель-в-пиксель как в браузере, сложные шрифты, эмодзи или JS-графики — DomPDF не потянет. Тогда рендерят через headless-браузер (пакеты поверх Chromium). Цена: в проде нужен установленный Chromium и больше памяти на процесс. Для типовых счетов и актов это избыточно, для сложных макетов — единственный вариант.
Общая ошибка обоих пакетов: заголовки ответа
Скачивается пустой или битый файл, хотя генерация прошла без ошибок. В 90% случаев виноват «мусор» до тела ответа: лишний пробел до <?php в подключённом файле, dd() или echo где-то в цепочке, включённый вывод отладки. Бинарный ответ ломается любым символом, отправленным раньше него.
Проверка: качайте файл через curl и смотрите первые байты. У xlsx они начинаются с сигнатуры ZIP (PK), у PDF — с %PDF. Если перед ними что-то есть — ищите преждевременный вывод.
# Смотрим начало ответа, не открывая файл в редакторе
curl -s http://localhost/exports/orders | head -c 20 | xxdЕсли не сработало
- Allowed memory size exhausted — коллекция вместо
FromQuery, либоchunkSizeслишком большой, либо жадная загрузка тяжёлых связей внутри чанка. - Maximum execution time exceeded / 504 — генерация в HTTP-запросе вместо очереди.
- Квадраты вместо русского текста в PDF — не подключён шрифт с кириллицей в CSS шаблона.
- Пустой или битый файл — вывод в поток до тела ответа; проверьте первые байты через
curl. - Вёрстка PDF разъехалась — flex/grid в шаблоне; переверстайте на таблицах.
Что настроить дальше
Замерьте пиковую память воркера очереди на реальном объёме данных (memory_get_peak_usage в конце экспорта) и подберите chunkSize под него, а не наугад. Настройте отдельную очередь под тяжёлые экспорты, чтобы они не блокировали быстрые задачи. Готовые файлы храните на диске с TTL и чистите по расписанию — иначе storage заполнится за пару месяцев. Точные имена конфиг-ключей и текущий API обоих пакетов сверяйте в их README на GitHub под вашу версию.
Карточки для маркетплейсов: Таблица товаров и папка фотографий на входе — комплект слайдов и описаний на выходе, по требованиям Wildberries и Ozon.
Спросить за 5 ₽Источники: maatwebsite/Laravel-Excel — документация, barryvdh/laravel-dompdf — репозиторий на GitHub
Частые вопросы
У меня старая версия maatwebsite/excel — сработает ли FromQuery?
Кириллица в PDF всё равно квадраты, шрифт подключил — что не так?
Можно ли отдавать файл сразу в ответ без очереди?
Экспорт валит воркер по памяти даже с чанками — почему?
Чем PDF через headless-браузер лучше DomPDF?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.