CSV кажется простым форматом: строки, разделители, кавычки. На практике файл, который нормально открывается в LibreOffice, в русском Excel превращается в одну колонку с кракозябрами. Причины обычно три: кодировка, разделитель и экранирование.
Почему CSV ломается в Excel
Стандарт RFC 4180 описывает запятую как разделитель полей и CRLF как перевод строки. Но Excel в русской локали ждёт точку с запятой: запятая у нас десятичный разделитель. Файл с запятыми он честно разложит в одну колонку.
Вторая проблема — кодировка. Excel не всегда определяет UTF-8 без подсказки. Подсказкой служит BOM, три байта в начале файла. Они не видны в тексте, но меняют то, как программа читает первую строку.
Разделитель и BOM
Для русской локали берите точку с запятой. BOM добавляйте, только если файл предназначен людям в Excel: сторонние парсеры и загрузчики иногда включают BOM в имя первого поля.
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="report.csv"');
$fh = fopen('php://output', 'wb');
fwrite($fh, "\xEF\xBB\xBF");
fputcsv($fh, ['Дата', 'Товар', 'Сумма'], ';', '"', '');
foreach ($rows as $row) {
fputcsv($fh, $row, ';', '"', '');
}
fclose($fh);
Важно: до этих строк не должно быть никакого вывода. Лишний пробел после ?> или сообщение об ошибке попадёт в файл раньше заголовков, и загрузка сломается.
В PHP 8.4 параметр escape у fputcsv() стал обязательным для явного указания. Пустая строка '' отключает нестандартное экранирование обратным слешем — так файл соответствует RFC 4180. Подробнее об изменениях версии — в статье PHP 8.4: что нового.
Запись потоком, а не в память
Соблазн собрать весь CSV в строку и отдать одним куском понятен, но на выгрузке в 200 тысяч строк это лишние сотни мегабайт. php://output пишет сразу в ответ, поэтому память остаётся почти постоянной.
Если файл нужен на диске, откройте его в режиме wb и пишите порциями. Не используйте file_put_contents() внутри цикла: каждая итерация будет перезаписывать файл.
Для очень больших выборок из базы добавьте ->cursor() или chunk(). Иначе драйвер сначала загрузит все строки в память, и потоковая запись уже не спасёт.
Чтение больших файлов
Обратная задача встречается не реже: разобрать чужой CSV. Читайте построчно, а не через file_get_contents():
$fh = fopen($path, 'rb');
while (($row = fgetcsv($fh, 0, ';', '"', '')) !== false) {
// $row — массив полей
}
fclose($fh);
Третий аргумент — разделитель, четвёртый — ограничитель строк, пятый — символ экранирования. Если файл пришёл с запятыми, поменяйте разделитель. Не определяйте его «на глаз» по первой строке: в данных может встретиться и то, и другое.
Пустые строки fgetcsv() возвращает как массив с одним пустым значением, а не как false. Проверяйте это отдельно, иначе в базу попадут пустые записи.
Формулы и безопасность
Ячейка, начинающаяся с =, +, - или @, в Excel становится формулой. Если вы выгружаете данные, введённые пользователями, злоумышленник может подставить =HYPERLINK(...) или команду для внешнего вызова. Это называется CSV injection.
function safeCell(string $value): string
{
$value = ltrim($value);
if ($value !== '' && in_array($value[0], ['=', '+', '-', '@'], true)) {
return "'" . $value;
}
return $value;
}
Апостроф Excel воспринимает как признак текста и не показывает в ячейке. Альтернатива — префикс из пробела, но он попадёт в данные при обратном импорте.
Кодировки и старые системы
Современный Excel читает UTF-8 с BOM. Старые версии и некоторые бухгалтерские программы ждут CP1251. Если файл нужен им, конвертируйте каждую строку через mb_convert_encoding($line, 'CP1251', 'UTF-8') и не добавляйте BOM.
Числа с десятичной точкой в русской локали Excel может принять за текст. Для денежных сумм надёжнее отдавать число с запятой и без разделителей тысяч либо использовать отдельный формат XLSX. Но XLSX — уже не CSV: там нужна библиотека вроде PhpSpreadsheet, а не fputcsv().
Когда CSV — плохой выбор
Если данные предназначены другой программе, а не человеку с Excel, CSV теряет типы: даты, числа и null становятся строками. Для обмена между сервисами лучше JSON — о разборе и ошибках есть статья JSON в PHP. А если данные забирают через API, посмотрите материал про парсинг JSON API.
Коротко
- для русского Excel — разделитель
;и BOM; - пишите через
php://outputиfputcsv(); - в PHP 8.4 указывайте
escapeявно; - читайте файл построчно, проверяйте пустые строки;
- нейтрализуйте ячейки, начинающиеся с
= + - @.