Реализация экспорта товаров из сайта в CSV/Excel/XML/JSON
Экспорт каталога нужен для разных задач: загрузка на маркетплейсы, отправка партнёрам, бэкап, интеграция с ERP/1С, аналитика. Каждый получатель хочет свой формат и своё подмножество полей. Задача — построить гибкий экспорт, который обслуживает все эти сценарии без дублирования кода.
Архитектура: Builder + Writer
ExportBuilder
└─> определяет набор полей, фильтры, сортировку
└─> итерирует данные из БД чанками
└─> передаёт строки в ExportWriter
ExportWriter (один из):
├─> CsvWriter
├─> ExcelWriter
├─> XmlWriter
└─> JsonWriter
interface ExportWriterInterface
{
public function open(string $filePath): void;
public function writeHeader(array $columns): void;
public function writeRow(array $row): void;
public function close(): string; // возвращает путь к файлу
}
CSV-писатель
class CsvWriter implements ExportWriterInterface
{
private $handle;
public function open(string $filePath): void
{
$this->handle = fopen($filePath, 'w');
// BOM для корректного открытия в Excel
fwrite($this->handle, "\xEF\xBB\xBF");
}
public function writeHeader(array $columns): void
{
fputcsv($this->handle, $columns, ';', '"');
}
public function writeRow(array $row): void
{
fputcsv($this->handle, $row, ';', '"');
}
public function close(): string
{
fclose($this->handle);
return $this->filePath;
}
}
Excel-писатель (потоковый, без лишней памяти)
Для больших каталогов PhpSpreadsheet потребляет огромно памяти. Используем openspout/openspout — работает потоково:
class ExcelWriter implements ExportWriterInterface
{
private \OpenSpout\Writer\XLSX\Writer $writer;
public function open(string $filePath): void
{
$this->writer = new \OpenSpout\Writer\XLSX\Writer();
$this->writer->openToFile($filePath);
}
public function writeHeader(array $columns): void
{
$cells = array_map(fn($c) => \OpenSpout\Common\Entity\Cell::fromValue($c), $columns);
$this->writer->addRow(new \OpenSpout\Common\Entity\Row($cells, null));
}
public function writeRow(array $row): void
{
$cells = array_map(fn($v) => \OpenSpout\Common\Entity\Cell::fromValue($v), $row);
$this->writer->addRow(new \OpenSpout\Common\Entity\Row($cells, null));
}
public function close(): string
{
$this->writer->close();
return $this->filePath;
}
}
openspout пишет строки напрямую в ZIP-поток .xlsx файла — потребление памяти ~10 МБ независимо от размера файла.
XML-писатель
class XmlWriter implements ExportWriterInterface
{
private \XMLWriter $xml;
public function open(string $filePath): void
{
$this->xml = new \XMLWriter();
$this->xml->openUri($filePath);
$this->xml->startDocument('1.0', 'UTF-8');
$this->xml->startElement('products');
}
public function writeHeader(array $columns): void
{
$this->columns = $columns; // сохраняем для writeRow
}
public function writeRow(array $row): void
{
$this->xml->startElement('product');
foreach (array_combine($this->columns, $row) as $key => $value) {
$this->xml->startElement($key);
$this->xml->text((string) $value);
$this->xml->endElement();
}
$this->xml->endElement();
}
public function close(): string
{
$this->xml->endElement(); // products
$this->xml->endDocument();
$this->xml->flush();
return $this->filePath;
}
}
JSON-писатель (NDJSON для потоковой обработки)
Для больших экспортов вместо одного большого JSON-массива — NDJSON (одна строка = один объект):
class NdjsonWriter implements ExportWriterInterface
{
private $handle;
private array $columns;
public function writeRow(array $row): void
{
$obj = array_combine($this->columns, $row);
fwrite($this->handle, json_encode($obj, JSON_UNESCAPED_UNICODE) . "\n");
}
}
Стандартный JSON-массив тоже поддерживается через JsonWriter, но для файлов >50 МБ NDJSON предпочтительнее.
Builder: формирование запроса и итерация
class ProductExportBuilder
{
private array $fields = ['sku', 'name', 'price', 'qty'];
private array $filters = [];
private int $chunkSize = 1000;
public function withFields(array $fields): self
{
$this->fields = $fields;
return $this;
}
public function withFilter(string $field, mixed $value): self
{
$this->filters[$field] = $value;
return $this;
}
public function export(ExportWriterInterface $writer, string $filePath): ExportResult
{
$writer->open($filePath);
$writer->writeHeader($this->fields);
$total = 0;
$query = $this->buildQuery();
$query->chunk($this->chunkSize, function ($products) use ($writer, &$total) {
foreach ($products as $product) {
$row = array_map(fn($f) => $this->resolveField($product, $f), $this->fields);
$writer->writeRow($row);
$total++;
}
});
$filePath = $writer->close();
return new ExportResult($total, $filePath);
}
private function buildQuery(): \Illuminate\Database\Eloquent\Builder
{
$query = Product::query()->orderBy('id');
foreach ($this->filters as $field => $value) {
$query->where($field, $value);
}
return $query;
}
private function resolveField(Product $product, string $field): mixed
{
return match ($field) {
'category' => $product->category?->name,
'images' => implode(',', $product->images->pluck('url')->all()),
'attributes' => $this->formatAttributes($product),
default => $product->{$field},
};
}
}
Асинхронный экспорт больших файлов
Для каталогов >10 000 позиций экспорт выполняется в фоновом режиме:
class ExportProductsJob implements ShouldQueue
{
public int $timeout = 600;
public function handle(ProductExportBuilder $builder): void
{
$filePath = storage_path("exports/products_{$this->exportId}.{$this->format}");
$writer = ExportWriterFactory::make($this->format);
$result = $builder
->withFields($this->config['fields'])
->withFilter('source_id', $this->config['source_id'] ?? null)
->export($writer, $filePath);
ExportFile::find($this->exportId)->update([
'status' => 'ready',
'file_path' => $filePath,
'total_rows' => $result->total,
'completed_at' => now(),
]);
// Уведомить пользователя о готовности
$this->user->notify(new ExportReadyNotification($this->exportId));
}
}
Конфигурируемые шаблоны экспорта
Разные получатели хотят разные наборы полей. Шаблоны хранятся в БД:
{
"name": "Для Яндекс.Маркета",
"format": "xml",
"fields": ["sku", "name", "price", "qty", "description", "category", "images", "brand"],
"filters": {"in_stock": true},
"transform": {
"price": "round:2",
"images": "first_only"
}
}
Раздача готового файла
public function download(int $exportId): BinaryFileResponse
{
$export = ExportFile::findOrFail($exportId);
$this->authorize('download', $export);
if ($export->status !== 'ready') {
abort(202, 'Export is not ready yet');
}
return response()->download(
storage_path($export->file_path),
"products_{$export->created_at->format('Y-m-d_H-i')}.{$export->format}",
['Content-Type' => $this->mimeType($export->format)]
);
}
Сроки реализации
- CSV + Excel (openspout) + Builder с чанкированием — 2 дня
- XML + JSON/NDJSON + асинхронный Job + уведомление — +1 день
- Шаблоны экспорта в БД, трансформации полей, скачивание — +1 день







