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/phpspreadsheet
setDelimiter($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

ApproachBest forTradeoff
PhpSpreadsheetStyled output, multiple sheets, formula supportComposer required; higher memory usage for large files
Native fgetcsv + ZipArchiveSimple data, zero extra dependenciesNo styling, single sheet only, manual OOXML maintenance
ChangeThisFile APINo composer, shared hosting, quick one-offsNetwork 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.