Генерация PDF и Excel в Laravel: рабочие приёмы
Как отдавать пользователям счета в PDF и отчёты в Excel без падений по памяти и таймаутов: выбор библиотек, потоковая запись и вынос генерации в очередь.

Клиент нажимает «Скачать отчёт», а через 30 секунд получает белый экран и 504 от Nginx. В логах — Allowed memory size of 134217728 bytes exhausted. Отчёт на 40 тысяч строк собирался в память целиком, PHP-процесс упёрся в memory_limit, воркер умер. Это самый частый сценарий, в котором генерация PDF и Excel в Laravel ломается на реальных данных. Разберём, чем генерировать, где память утекает и как вынести тяжёлое в фон.
Всё ниже — под Laravel 10.x/11.x и PHP 8.2+. Библиотеки: maatwebsite/excel (обёртка над PhpSpreadsheet) для таблиц, barryvdh/laravel-dompdf и spatie/laravel-pdf (обёртка над Browsershot/Puppeteer) для PDF. Имена пакетов и классов сверяйте с их README перед установкой — API мажорных версий меняется.
Excel: три способа отдать таблицу и чем каждый платит
Задача выглядит одинаково — превратить выборку из базы в .xlsx. Но между «10 строк настроек» и «выгрузка заказов за год» пропасть по памяти.
1. FromCollection — просто и опасно
Самый короткий путь из документации maatwebsite/excel: класс экспорта реализует FromCollection и возвращает коллекцию. Работает мгновенно на сотнях строк и разваливается на десятках тысяч, потому что вся выборка и весь объект таблицы держатся в памяти одновременно.
<?php
namespace App\Exports;
use App\Models\Order;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
class OrdersExport implements FromCollection, WithHeadings
{
public function collection()
{
// вся таблица заказов улетает в память разом
return Order::select('id', 'total', 'created_at')->get();
}
public function headings(): array
{
return ['ID', 'Сумма', 'Дата'];
}
}2. FromQuery + чанки — рабочий вариант для больших выгрузок
Заменяем FromCollection на FromQuery и добавляем WithChunkReading. Пакет сам берёт запрос, выбирает данные порциями по N строк и не держит всю выборку. Это тот интерфейс, который стоит использовать по умолчанию для любой выгрузки из базы.
<?php
namespace App\Exports;
use App\Models\Order;
use Illuminate\Contracts\Database\Eloquent\Builder;
use Maatwebsite\Excel\Concerns\FromQuery;
use Maatwebsite\Excel\Concerns\WithChunkReading;
use Maatwebsite\Excel\Concerns\WithHeadings;
class OrdersExport implements FromQuery, WithChunkReading, WithHeadings
{
public function query(): Builder
{
// возвращаем построитель, а не результат — пакет сам разобьёт на порции
return Order::query()->select('id', 'total', 'created_at');
}
public function chunkSize(): int
{
return 1000; // строк за одну итерацию
}
public function headings(): array
{
return ['ID', 'Сумма', 'Дата'];
}
}3. Прямая запись в CSV мимо PhpSpreadsheet
Если формат не принципиален и заказчику подходит .csv, не тащите PhpSpreadsheet вообще. Курсор Eloquent (cursor()) отдаёт по одной модели, а fputcsv пишет в поток. Память при этом почти не растёт независимо от числа строк — платите форматированием: ни стилей, ни ширины колонок, ни нескольких листов.
| Способ | Память на 100k строк | Форматирование | Когда брать |
|---|---|---|---|
| FromCollection | Высокая, риск OOM | Полное | До ~2–3 тыс. строк, настройки, справочники |
| FromQuery + чанки | Умеренная | Полное | Основной вариант для выгрузок из БД |
| cursor + fputcsv | Низкая, почти константа | Нет | Огромные объёмы, формат не важен |
Правило, которое экономит вечера: как только выгрузка зависит от пользовательских данных, а не от фиксированного справочника, её объём непредсказуем. Проектируйте под чанки или курсор сразу, а не после первого падения на проде.
PDF: HTML-движок против безголового браузера
Для PDF в Laravel два принципиально разных подхода, и путать их — источник половины проблем с вёрсткой.
- DomPDF (
barryvdh/laravel-dompdf) — рендерит HTML в PHP. Не требует ничего кроме PHP, работает быстро, но понимает ограниченный CSS: flexbox и grid игнорирует, современные шрифты подключаются с бубном. Годится для счетов, актов, накладных — документов с табличной вёрсткой. - Browsershot / spatie-pdf — открывает страницу в headless Chromium (Puppeteer) и печатает в PDF. Поддерживает любой CSS, который тянет Chrome. Цена: на сервере должен стоять Node.js и Chromium, старт браузера — сотни миллисекунд на каждый вызов, память на процесс браузера измеряется сотнями мегабайт.
Счёт через DomPDF
<?php
namespace App\Http\Controllers;
use App\Models\Invoice;
use Barryvdh\DomPDF\Facade\Pdf;
class InvoiceController extends Controller
{
public function download(Invoice $invoice)
{
// шаблон resources/views/invoices/pdf.blade.php с табличной вёрсткой
$pdf = Pdf::loadView('invoices.pdf', ['invoice' => $invoice]);
return $pdf->download("invoice-{$invoice->id}.pdf");
}
}Кириллица в DomPDF — отдельная боль. Стандартный шрифт её не содержит, буквы превращаются в квадраты. Подключите шрифт с поддержкой кириллицы (например, DejaVu Sans) через @font-face в шаблоне и включите загрузку шрифтов в конфиге пакета. Точные ключи конфигурации смотрите в README laravel-dompdf для вашей мажорной версии — они менялись.
Почему тяжёлую генерацию нельзя делать в HTTP-запросе
Синхронная генерация в контроллере упирается сразу в три потолка: max_execution_time PHP, таймаут proxy в Nginx (proxy_read_timeout, по умолчанию 60 секунд) и memory_limit. Отчёт на минуту гарантированно словит 504, даже если сама генерация успешна — пользователь уже увидел ошибку и жмёт «скачать» повторно, плодя параллельные тяжёлые процессы.
Правильная схема для всего, что дольше пары секунд:
- Запрос от пользователя ставит job в очередь и сразу возвращает ответ «файл готовится».
- Воркер очереди генерирует файл и кладёт его в хранилище через
store()-метод экспорта на нужный диск (storage/appили S3). - Пользователь получает уведомление или опрашивает статус, затем скачивает готовый файл по временной ссылке.
maatwebsite/excel умеет ставить экспорт в очередь методом queue() вместо download() — он сам режет работу на chunk-jobs. Для этого нужен настроенный драйвер очереди (Redis или database) и запущенный php artisan queue:work. Проверьте, что воркер жив: без него job молча копится в таблице jobs, и файл не появляется никогда.
<?php
use App\Exports\OrdersExport;
use Maatwebsite\Excel\Facades\Excel;
// вместо синхронной отдачи — ставим в очередь и пишем в приватный диск
Excel::queue(new OrdersExport, 'exports/orders-' . now()->timestamp . '.xlsx', 'local');Что ломается на проде
- OOM на воркере. Даже с чанками PhpSpreadsheet держит открытый лист в памяти. Уменьшайте
chunkSize, а стили и формулы отключайте — каждая ячейка со стилем весит больше голого значения. - Browsershot не находит Chromium. На проде под другим пользователем Node и браузер могут быть недоступны. Задайте явные пути к
nodeи Chromium в вызове Browsershot; тестируйте от того же юзера, под которым крутится воркер, а не из-под своей консоли. - Файлы копятся в storage. Сгенерированные отчёты никто не удаляет — диск забивается за недели. Заведите команду очистки по расписанию: удаляйте выгрузки старше N дней.
- N+1 внутри экспорта. Если в строке таблицы обращаетесь к связям модели, добавляйте
with()в запрос экспорта, иначе на каждую из 50 тысяч строк уйдёт отдельный SQL-запрос и генерация растянется на минуты.
Что настроить дальше
Поднимите очередь на Redis и queue:work под supervisor, чтобы воркер переживал перезапуск. Добавьте в планировщик очистку старых файлов экспорта. Для PDF решите на старте: если документы табличные — оставайтесь на DomPDF и не тащите Chromium на сервер; если нужна сложная вёрстка — закладывайте память и время старта браузера в расчёт нагрузки. И померяйте реальную выгрузку на боевом объёме данных, а не на десятке тестовых строк — именно там всплывают память и таймауты.
Карточки для маркетплейсов: Таблица товаров и папка фотографий на входе — комплект слайдов и описаний на выходе, по требованиям Wildberries и Ozon.
Спросить за 5 ₽Источники: Laravel Excel — официальная документация, spatie/laravel-pdf — GitHub
Частые вопросы
А если у меня Laravel 9 или PHP 8.1 — приёмы сработают?
Экспорт в очереди не создаёт файл, ошибок нет. Что смотреть?
Кириллица в PDF выходит квадратами — как чинить?
Что выбрать для PDF — DomPDF или Browsershot?
Насколько уменьшать chunkSize, если воркер всё равно падает по памяти?
Можно ли отдавать файл прямо в ответе, если строк немного?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.