Converting CSV to XLSX in PHP sounds trivial but hides a few real problems: UTF-8 BOM detection, delimiter sniffing, and numeric string preservation (ZIP codes becoming integers). PhpSpreadsheet handles most of these correctly. For one-off conversions or environments where you can't run composer, the API is the zero-install path.
Method 1: PhpSpreadsheet (full Excel feature support)
PhpSpreadsheet is the successor to PHPExcel. It reads CSV and writes XLSX with full support for styles, formulas, and multiple sheets.
composer require phpoffice/phpspreadsheetsetDelimiter($delimiter);
$reader->setEnclosure('"');
$reader->setSheetIndex(0);
$reader->setInputEncoding(CsvReader::GUESS_ENCODING); // auto-detect UTF-8/BOM
$spreadsheet = $reader->load($csvPath);
$sheet = $spreadsheet->getActiveSheet();
// Auto-size columns for readability
foreach (range('A', $sheet->getHighestDataColumn()) as $col) {
$sheet->getColumnDimension($col)->setAutoSize(true);
}
// Style the header row
$headerRange = 'A1:' . $sheet->getHighestDataColumn() . '1';
$sheet->getStyle($headerRange)->getFont()->setBold(true);
$sheet->getStyle($headerRange)->getFill()
->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)
->getStartColor()->setRGB('E2E8F0');
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
$writer->save($outPath);
$spreadsheet->disconnectWorksheets();
}
csvToXlsx('data.csv', 'output.xlsx');
echo "Done\n";
The GUESS_ENCODING option handles UTF-8 BOM correctly — a common issue with Excel-exported CSVs that have a BOM prefix. Without it, the first column header may include a BOM character (\xEF\xBB\xBF).
If your CSV has numeric strings that should stay as text (ZIP codes, phone numbers, IDs), set the column type explicitly:
getStyle('A:A')->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_TEXT);
Method 2: Native fgetcsv + ZipArchive (minimal dependencies)
XLSX is a ZIP of XML files. For simple conversions without PhpSpreadsheet, you can write the OOXML directly using PHP's built-in ZipArchive and fgetcsv.
$row) {
$cells = '';
foreach ($row as $c => $val) {
$col = chr(65 + $c); // A, B, C...
$rowNum = $r + 1;
if (is_numeric($val)) {
$cells .= "" . htmlspecialchars($val) . " ";
} else {
$idx = $strIndex($val);
$cells .= "{$idx} ";
}
}
$sheetRows .= "{$cells}
";
}
$sheetXml = '
' . $sheetRows . '
';
$ssXml = '
';
foreach ($sharedStrings as $s) {
$ssXml .= '' . htmlspecialchars($s) . ' ';
}
$ssXml .= ' ';
// Write ZIP
$zip = new ZipArchive();
$zip->open($outPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
$zip->addFromString('[Content_Types].xml', ' ');
$zip->addFromString('_rels/.rels', ' ');
$zip->addFromString('xl/workbook.xml', ' ');
$zip->addFromString('xl/_rels/workbook.xml.rels', ' ');
$zip->addFromString('xl/worksheets/sheet1.xml', $sheetXml);
$zip->addFromString('xl/sharedStrings.xml', $ssXml);
$zip->close();
}
csvToXlsxMinimal('data.csv', 'output.xlsx');
This minimal approach only supports single-sheet workbooks and no styling. Use PhpSpreadsheet for anything production-grade.
Method 3: ChangeThisFile API (no composer install)
The API converts CSV to XLSX server-side. Source format is auto-detected from the filename. Free tier: 1,000 conversions/month, no card required.
true,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey],
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => [
'file' => new CURLFile($csvPath, 'text/csv', basename($csvPath)),
'target' => 'xlsx',
],
]);
$result = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) throw new RuntimeException("API error: HTTP {$code}");
file_put_contents($outPath, $result);
}
csvToXlsxApi('export.csv', 'output.xlsx', 'ctf_sk_your_key_here');
echo "Done\n";
When to use each
| Approach | Best for | Tradeoff |
|---|---|---|
| PhpSpreadsheet | Styled output, multiple sheets, formula support | Composer required; higher memory usage for large files |
| Native fgetcsv + ZipArchive | Simple data, zero extra dependencies | No styling, single sheet only, manual OOXML maintenance |
| ChangeThisFile API | No composer, shared hosting, quick one-offs | Network call; free tier 25MB limit |
Production tips
- Increase memory_limit for large CSVs. PhpSpreadsheet loads the entire file into memory. A 50,000-row CSV can use 128MB+. Set memory_limit = 512M in php.ini or use PhpSpreadsheet's chunk reading for very large files.
- Detect delimiter automatically. User-uploaded CSVs may use ; or \t as delimiters. Sniff with: $line = fgets($fh); $delimiter = (substr_count($line, ';') > substr_count($line, ',')) ? ';' : ',';
- Preserve leading zeros in numeric columns. ZIP codes, phone numbers, and IDs with leading zeros become integers unless you set the column type to FORMAT_TEXT explicitly.
- Stream large XLSX to browser. For downloads, use ob_end_clean() + header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + $writer->save('php://output').
PhpSpreadsheet is the right call for any production CSV-to-XLSX pipeline where you need proper column types or styled output. For quick conversions without a composer step, the API needs only curl. Free tier: 1,000 conversions/month.