Экспорт в Excel в Laravel: рабочие приёмы без утечек памяти
Как отдавать пользователю выгрузку из Laravel, чтобы 200 тысяч строк не уронили процесс по памяти и не заставили клиента ждать таймаута. Разбираем подходы maatwebsite/excel и когда от него отказаться.

Задача звучит просто: «дай кнопку — выгрузить заказы в Excel». На тысяче строк работает любой код. На 200 тысячах строк наивная реализация через Excel::download собирает всю коллекцию в память, генерирует объектную модель книги целиком и падает с Allowed memory size exhausted либо упирается в max_execution_time. Разберём, чем платит каждый подход и когда xlsx вообще не нужен — часто пользователю хватает CSV, который отдаётся стримом за секунды.
Материал написан под Laravel 11.x и пакет maatwebsite/excel 3.1.x (обёртка над PhpSpreadsheet). Инвалидация очередей и вёрстка сложных многолистовых отчётов — отдельные темы, здесь только выгрузка табличных данных из базы.
Почему очевидное решение течёт
Стандартный пример из документации выглядит так:
// app/Exports/OrdersExport.php — наивный вариант
namespace App\Exports;
use App\Models\Order;
use Maatwebsite\Excel\Concerns\FromCollection;
class OrdersExport implements FromCollection
{
public function collection()
{
// все заказы разом в память + Eloquent-модели поверх массивов
return Order::all();
}
}
Order::all() гидрирует каждую строку в объект Eloquent, а PhpSpreadsheet держит в памяти всю книгу как дерево ячеек. На широкой таблице один объект-модель плюс представление ячеек легко съедает килобайты на строку. Умножьте на сотни тысяч — и memory_limit в 512 МБ кончается.
У проблемы две независимые части: как читать данные из базы и как писать их в файл. Оптимизировать надо обе.
Четыре подхода и их цена
1. FromCollection — только для мелочи
Собирает всё в память. Годится для справочников и отчётов до нескольких тысяч строк, которые точно не вырастут. Плюс — минимум кода. Минус — не масштабируется вообще.
2. FromQuery + чанки
Отдаёте пакету Builder вместо коллекции. Пакет сам разбивает выборку на чанки и не держит все модели одновременно. Размер чанка задаётся WithBatchSize (сколько строк писать за раз) и настройкой чтения из базы.
// app/Exports/OrdersExport.php
namespace App\Exports;
use App\Models\Order;
use Maatwebsite\Excel\Concerns\FromQuery;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Concerns\WithBatchSize;
class OrdersExport implements FromQuery, WithHeadings, WithMapping, WithBatchSize
{
public function query()
{
// возвращаем именно билдер, не ->get()
return Order::query()->select(['id', 'number', 'total', 'created_at']);
}
public function headings(): array
{
return ['ID', 'Номер', 'Сумма', 'Дата'];
}
// приводим строку к нужному виду
public function map($order): array
{
return [
$order->id,
$order->number,
$order->total,
$order->created_at->format('d.m.Y'),
];
}
public function batchSize(): int
{
return 1000;
}
}
Это снимает нагрузку на чтение из базы. Но xlsx всё равно строится в памяти целиком: PhpSpreadsheet держит всю книгу, пока не сохранит файл. Для по-настоящему больших выгрузок этого мало.
3. Очередь + чанковая запись на диск
Если строк сотни тысяч, синхронный ответ по HTTP не вариант: пользователь получит таймаут раньше, чем файл соберётся. Реализуйте интерфейс ShouldQueue — пакет разложит генерацию на джобы, каждая обработает свой чанк, а готовый файл положит на диск. Пользователь потом скачивает его по ссылке или получает уведомление.
// контроллер: ставим генерацию в очередь на диск 'exports'
use App\Exports\OrdersExport;
use Maatwebsite\Excel\Facades\Excel;
public function export()
{
// queue вернёт PendingDispatch, файл появится асинхронно
Excel::queue(new OrdersExport, 'orders/'.now()->timestamp.'.xlsx', 'exports');
return response()->json(['status' => 'queued']);
}
Чтобы ShouldQueue сработал, у экспорта должен быть FromQuery: только билдер можно разбить на страницы между джобами. С FromCollection очередь смысла не имеет.
4. CSV стримом — когда xlsx не нужен
Спросите заказчика, зачем именно xlsx. Если файл открывают в Excel и работают с ним как с таблицей — CSV подойдёт и отдаётся потоково через StreamedResponse вообще без PhpSpreadsheet. Память при этом почти не растёт: строки уходят в выходной поток по мере чтения из курсора.
use App\Models\Order;
use Symfony\Component\HttpFoundation\StreamedResponse;
public function exportCsv(): StreamedResponse
{
$headers = [
'Content-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => 'attachment; filename="orders.csv"',
];
return response()->stream(function () {
$out = fopen('php://output', 'w');
// BOM, чтобы Excel не ломал кириллицу
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, ['ID', 'Номер', 'Сумма', 'Дата']);
// cursor() читает по одной строке, не грузит всё в память
foreach (Order::query()->select(['id', 'number', 'total', 'created_at'])->cursor() as $order) {
fputcsv($out, [
$order->id,
$order->number,
$order->total,
$order->created_at->format('d.m.Y'),
]);
}
fclose($out);
}, 200, $headers);
}
BOM в начале файла нужен, иначе Excel на Windows откроет кириллицу кракозябрами. Это самая частая жалоба по CSV-выгрузкам.
Прежде чем оптимизировать генерацию xlsx на миллион строк, задайте один вопрос: точно ли пользователю нужен именно xlsx, а не CSV. В половине случаев это экономит вам джобы, диск и очередь.
Сравнение
| Подход | Память | Ответ | Когда брать |
|---|---|---|---|
| FromCollection | Всё в памяти | Синхронный | До нескольких тысяч строк, справочники |
| FromQuery + чанки | Читает пачками, книга целиком в памяти | Синхронный | Десятки тысяч строк, xlsx нужен сразу |
| Queue + FromQuery | Пачками, файл на диске | Асинхронный | Сотни тысяч строк, xlsx обязателен |
| CSV стримом | Почти не растёт | Синхронный, потоковый | Когда формат не жёстко xlsx |
Когда что выбирать
- Формат не принципиален и данных много — CSV стримом. Самый дешёвый путь по памяти и коду.
- Нужен именно xlsx, строк до десятков тысяч, ответ здесь и сейчас — FromQuery с
WithBatchSize. - Нужен xlsx на сотни тысяч строк — очередь. Отдавайте пользователю ссылку на готовый файл, не держите HTTP-соединение.
- Простой отчёт, который не вырастет — FromCollection, не усложняйте.
Что ломается на практике
- Даты и числа как текст. Excel показывает
00123без нуля или превращает длинный номер в экспоненту. Форматирование ячеек задаётся черезWithColumnFormatting, а не надеждой на автоопределение. - N+1 в
map(). Если внутриmap()дёргаете связь$order->customer->nameбезwith()в запросе, чанки не спасут — база получит запрос на каждую строку. Добавляйте->with(['customer'])в билдер. - Тяжёлый запрос в очереди течёт мимо чанков. Если в
query()естьorderByпо вычисляемому полю или сложный join, PhpSpreadsheet/PhpSpreadsheet будет пересчитывать выборку на каждой странице. Проверяйте, что пагинация билдера работает по индексируемому ключу. - Кириллица в CSV. Без BOM Excel читает файл в ANSI. Лечится строкой
\xEF\xBB\xBFв начале потока. - Файл из очереди не находится. Диск в
Excel::queue(..., $disk)должен быть объявлен вconfig/filesystems.php, иначе джоба упадёт с ошибкой доступа к хранилищу.
Что настроить дальше
Замерьте пиковую память своей выгрузки: оберните экспорт в memory_get_peak_usage(true) до и после на реальном объёме данных, а не на десяти тестовых строках. Если растёт линейно с числом строк — вы всё ещё держите книгу в памяти, переходите на очередь или CSV. Для асинхронных выгрузок настройте очистку старых файлов на диске exports по расписанию, иначе диск забьётся. Точный список доступных интерфейсов и опций форматирования сверяйте в документации maatwebsite/excel для вашей версии — API концернов менялся между 3.0 и 3.1.
Субтитры и таймкоды: Расшифровка речи и субтитры по правилам читаемости: 42 знака в строке, две строки, минимум секунда на реплику.
Спросить за 40 ₽Источники: Laravel Excel — официальная документация (maatwebsite/excel), Laravel 11 — Responses (Streamed Responses)
Частые вопросы
У меня версия maatwebsite/excel 3.0, код с очередью не работает так же?
Стрим CSV падает с таймаутом на большой выгрузке, что делать?
Почему при экспорте в xlsx номера заказов превращаются в экспоненту?
Нужен ли Redis для очереди экспорта?
Как отдать пользователю файл, если генерация в очереди?
FromQuery всё равно ест много памяти, я что-то делаю не так?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.