Реалізація експорту товарів із сайту у 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 день







