A supplier sent an Excel file with merged cells, a CSV in Windows-1251, and an XML with non-standard tags. Manual processing meant hours of routine and sync errors. A client in electronics received price lists from 12 suppliers in different formats: some sent Excel with pivot tables, others CSV in KOI8-R, others XML with namespaces. Manual loading took 2 days a week, and errors led to incorrect stock levels. We wrote a universal parser with a config for each supplier — now updates take 5 minutes daily, saving over 40 hours per month and approximately $2,500 monthly. Over 5 years, we have developed such parsers for 50+ projects, and here’s how it works.
Common Challenges in Price List Parsing
- Excel: data starts not from the first row, headers in merged cells, numbers stored as text, dates in Excel format.
- CSV: encoding Windows-1251, delimiter semicolon, prices with spaces or dots.
- XML: different schemas — products can be attributes, nested elements, or namespaces.
- Inconsistent SKUs: one supplier writes article as '12345', another as 'AB-123/45'. The parser normalizes everything to a unified format.
- N+1 query: loading products one by one would crash the database. We use chunks of 500 records and queues — this is 10 times faster than row-by-row import.
Compare formats by key parameters:
| Format | Configuration flexibility | Parsing speed | Implementation complexity |
|---|---|---|---|
| Excel | High | Medium | Medium (handling multi-row data) |
| CSV | Medium | High | Low (auto-detection of delimiter) |
| XML | Very high | Low | High (XPath, namespaces) |
Handling Merged Cells in Excel
We use the PhpSpreadsheet library (https://github.com/PHPOffice/PhpSpreadsheet). The key technique is reading the value from the top-left cell of the merged range. In the config, we specify the header row, data start row, and column aliases. The parser automatically maps headers to your fields, ignoring empty merged cells. This saves up to 90% of data preparation time.
Importance of Auto-Detection for CSV Encoding
Suppliers often use Windows-1251, and some use KOI8-R. Without auto-detection, data comes as gibberish. Our parser, using mb_detect_encoding, converts the content to UTF-8 before parsing. It also automatically guesses the delimiter (comma, semicolon, tab) by analyzing the first line. As a result, encoding errors are reduced to 0.1% of cases.
How We Design a Universal Parser
We use the PhpSpreadsheet library for Excel, built-in PHP functions for CSV, and SimpleXML for XML. The classic Repository pattern isolates parsing logic from the data source. Below are the key classes.
Excel Parser (PhpSpreadsheet) – a PHP Excel Parser Implementation
// app/Services/PriceList/ExcelParser.php
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
class ExcelParser
{
private array $columnMap = [];
public function parse(string $filePath, array $config): array
{
$spreadsheet = IOFactory::load($filePath);
$sheet = $spreadsheet->getSheetByName($config['sheet_name'] ?? null)
?? $spreadsheet->getActiveSheet();
$headerRow = $config['header_row'] ?? 1;
$dataStartRow = $config['data_start_row'] ?? 2;
// Detect column mapping from headers
$this->detectColumns($sheet, $headerRow, $config['column_aliases']);
$products = [];
$highestRow = $sheet->getHighestRow();
for ($row = $dataStartRow; $row <= $highestRow; $row++) {
$sku = $this->getCellValue($sheet, $this->columnMap['sku'], $row);
if (empty($sku)) continue;
$products[] = [
'sku' => trim($sku),
'name' => $this->getCellValue($sheet, $this->columnMap['name'] ?? null, $row),
'price' => $this->parsePrice($this->getCellValue($sheet, $this->columnMap['price'], $row)),
'in_stock' => $this->parseStock($this->getCellValue($sheet, $this->columnMap['stock'] ?? null, $row)),
'category' => $this->getCellValue($sheet, $this->columnMap['category'] ?? null, $row),
];
}
return $products;
}
private function detectColumns(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $sheet, int $headerRow, array $aliases): void
{
$highestCol = $sheet->getHighestColumn();
$highestColIndex = Coordinate::columnIndexFromString($highestCol);
for ($col = 1; $col <= $highestColIndex; $col++) {
$header = strtolower(trim(
$sheet->getCellByColumnAndRow($col, $headerRow)->getValue() ?? ''
));
foreach ($aliases as $fieldName => $possibleHeaders) {
foreach ($possibleHeaders as $alias) {
if (str_contains($header, strtolower($alias))) {
$this->columnMap[$fieldName] = $col;
break 2;
}
}
}
}
}
private function parsePrice(mixed $value): float
{
if (is_numeric($value)) return (float) $value;
return (float) preg_replace('/[^\d.]/', '', str_replace(',', '.', (string) $value));
}
private function parseStock(mixed $value): bool
{
if (is_null($value)) return true;
$s = strtolower(trim((string) $value));
return !in_array($s, ['0', 'нет', 'no', 'false', 'отсутствует', '']);
}
}
Configuration for a specific supplier:
// config/price_list_parsers.php
return [
'supplier_abc' => [
'format' => 'excel',
'sheet_name' => 'Price',
'header_row' => 2,
'data_start_row' => 3,
'column_aliases' => [
'sku' => ['Article', 'SKU', 'Product Code'],
'name' => ['Name', 'Title', 'Product'],
'price' => ['Price', 'Price, rub.', 'Cost'],
'stock' => ['Stock', 'Availability', 'Qty'],
'category' => ['Category', 'Group', 'Section'],
],
],
];
CSV Parser with Auto-Detection of Encoding
// app/Services/PriceList/CsvParser.php
class CsvParser
{
public function parse(string $filePath, array $config = []): array
{
$content = file_get_contents($filePath);
// Auto-detect encoding
$encoding = mb_detect_encoding($content, ['UTF-8', 'Windows-1251', 'KOI8-R'], true);
if ($encoding && $encoding !== 'UTF-8') {
$content = mb_convert_encoding($content, 'UTF-8', $encoding);
}
// Auto-detect delimiter
$delimiter = $config['delimiter'] ?? $this->detectDelimiter($content);
$lines = str_getcsv($content, "\n");
$headers = str_getcsv(array_shift($lines), $delimiter);
$headers = array_map('trim', $headers);
$products = [];
foreach ($lines as $line) {
if (empty(trim($line))) continue;
$row = str_getcsv($line, $delimiter);
if (count($row) !== count($headers)) continue;
$data = array_combine($headers, $row);
$products[] = $this->normalizeRow($data, $config);
}
return $products;
}
private function detectDelimiter(string $content): string
{
$firstLine = strtok($content, "\n");
$counts = [
',' => substr_count($firstLine, ','),
';' => substr_count($firstLine, ';'),
"\t" => substr_count($firstLine, "\t"),
];
return array_key_first(array_filter($counts, fn($c) => $c === max($counts)));
}
private function normalizeRow(array $row, array $config): array
{
$map = $config['field_map'] ?? [];
return [
'sku' => trim($row[$map['sku'] ?? 'sku'] ?? ''),
'name' => trim($row[$map['name'] ?? 'name'] ?? ''),
'price' => $this->parsePrice($row[$map['price'] ?? 'price'] ?? 0),
'in_stock' => !empty($row[$map['stock'] ?? 'stock']),
];
}
}
XML Parser
// app/Services/PriceList/XmlParser.php
class XmlParser
{
public function parse(string $filePath, array $config): array
{
$xml = simplexml_load_file($filePath, 'SimpleXMLElement', LIBXML_NOCDATA);
if ($xml === false) {
throw new \RuntimeException("Failed to parse XML: " . $filePath);
}
// XPath to extract products
$itemXpath = $config['item_xpath'] ?? '//item';
$items = $xml->xpath($itemXpath);
return array_map(fn($item) => $this->extractItem($item, $config), $items);
}
private function extractItem(\SimpleXMLElement $item, array $config): array
{
$fields = $config['fields'] ?? [];
$extract = function(string $path) use ($item): string {
// Support attributes (@attr) and child elements (tag)
if (str_starts_with($path, '@')) {
return (string) ($item->attributes()[substr($path, 1)] ?? '');
}
$nodes = $item->xpath($path);
return $nodes ? trim((string) $nodes[0]) : '';
};
return [
'sku' => $extract($fields['sku'] ?? 'article'),
'name' => $extract($fields['name'] ?? 'name'),
'price' => (float) $extract($fields['price'] ?? 'price'),
'in_stock' => $extract($fields['stock'] ?? 'available') !== '0',
'category' => $extract($fields['category'] ?? 'category'),
];
}
}
Format Dispatcher Facade
// app/Services/PriceList/PriceListParser.php
class PriceListParser
{
public function parse(string $filePath, array $config): array
{
$format = $config['format'] ?? $this->detectFormat($filePath);
return match ($format) {
'excel', 'xlsx', 'xls' => app(ExcelParser::class)->parse($filePath, $config),
'csv' => app(CsvParser::class)->parse($filePath, $config),
'xml' => app(XmlParser::class)->parse($filePath, $config),
default => throw new \InvalidArgumentException("Unknown format: {$format}"),
};
}
private function detectFormat(string $filePath): string
{
return match (strtolower(pathinfo($filePath, PATHINFO_EXTENSION))) {
'xlsx', 'xls', 'ods' => 'excel',
'csv', 'txt' => 'csv',
'xml' => 'xml',
default => 'csv',
};
}
}
More on mapping configuration
Each supplier gets their own config file, which defines:
- mapping of column headers to database fields
- date format (d.m.Y, Y-m-d, Excel serial)
- price transformation rules (remove spaces, replace comma with dot)
- stock indicator (number, 'yes'/'no')
When the format changes, simply edit this file — we do not touch the parser code. This allows adding a new supplier in 1–2 hours.
Project Timeline
| Scenario | Timeline |
|---|---|
| Parser for 1 supplier (1 format, standard structure) | 2–4 working days |
| Universal dispatcher + 5 suppliers with different formats | 7–10 working days |
Development cost is calculated individually and depends on the complexity of supplier formats. The investment pays off through reduced manual labor and improved data accuracy.
Deliverables Package
- Source code of the parser with configurations for each supplier
- Instructions for adding a new supplier (with examples)
- Configured automatic file retrieval (FTP, email, HTTP)
- Integration with your queue (Laravel Horizon / Supervisor)
- Test data for 3 suppliers
- 1 month of support after delivery
Our Expertise and Guarantees
We have 5 years of experience developing parsers for e-commerce. We have implemented over 50 integrations with suppliers from various industries. Our solutions work under loads of up to 100,000 products per import. We guarantee stability — all failures are handled with automatic notifications.
Contact us — we will analyze your suppliers and propose the optimal solution within 1 day. Order the parser development and get automation in 2–10 days.







