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 явно;
  • читайте файл построчно, проверяйте пустые строки;
  • нейтрализуйте ячейки, начинающиеся с = + - @.