Yandex Metrika
sanches.free

Экспорт в CSV: карта колонок, конвертеры и потоковая выдача в браузер

Зачем карта полей

Учебный пример с жёстко прошитыми ключами массива быстро превращается в копипасту: при добавлении поля правят и SQL, и шапку, и тело строки. Удобнее держать карту колонок: из базы берём сырой набор полей, а заголовки CSV и преобразования значений описываем один раз в конфигурации — по аналогии с массивом exportedValues из классической заметки про выгрузку пользователей.

Поток и память

Открывайте fopen('php://output', 'w') и пишите строки по мере чтения батчей из БД (например, по 100–500 строк). Так вы не раздуваете память на десятки тысяч записей. Заголовки HTTP отправляйте до любого вывода тела; в конце die() или exit в обработчике допустимы, если дальше по цепочке не должно идти HTML от шаблона.

Заголовки и Excel

Тройное Content-Type из старых примеров браузеру не нужно: достаточно типа для скачивания. Для кириллицы в Microsoft Excel разумно отдать UTF‑8 с BOM в начале потока (\xEF\xBB\xBF) и явно указать text/csv; charset=UTF-8. Имя файла в Content-Disposition задавайте с расширением .csv, если внутри действительно CSV — переименование в .xls только путает пользователей и антивирусы.

Разделитель и кавычки

fputcsv экранирует поля по правилам RFC; третий аргумент — разделитель (в РФ часто ; для Excel при локали с запятой как десятичным разделителем). При необходимости задайте enclosure и escape согласно документации PHP.

Пример: карта + конвертеры + батчи

Ниже — самодостаточный скелет без привязки к конкретному фреймворку. Источник данных подмените на свой (в «1С‑Битрикс» для пользователей уместен ORM Bitrix\Main\UserTable с фильтром и постраничкой через параметры limit и offset у getList).

declare(strict_types=1);

final class CsvExporter
{
    /** @var resource */
    private $stream;

    /** @var list<array{column:string,name:string,function?:string}> */
    private array $mapping;

    /**
     * @param callable(int,int): iterable<array<string,mixed>>|false $loadPage
     */
    public function __construct(
        private string $downloadFileName,
        array $mapping,
        private $loadPage
    ) {
        $this->mapping = $mapping;
    }

    public function send(): void
    {
        $this->sendHeaders($this->downloadFileName);
        $this->stream = fopen('php://output', 'w');
        if ($this->stream === false) {
            return;
        }
        fwrite($this->stream, "\xEF\xBB\xBF");
        $this->writeHead();
        $offset = 0;
        $limit = 200;
        while (($rows = ($this->loadPage)($offset, $limit)) !== false) {
            $batch = iterator_to_array($rows, false);
            if ($batch === []) {
                break;
            }
            foreach ($batch as $row) {
                $this->writeRow($row);
            }
            $offset += $limit;
        }
        fclose($this->stream);
    }

    private function sendHeaders(string $fileName): void
    {
        header('Content-Type: text/csv; charset=UTF-8');
        header('Content-Disposition: attachment; filename="' . basename($fileName) . '"');
        header('Cache-Control: no-store, private');
    }

    private function writeHead(): void
    {
        $head = [];
        foreach ($this->mapping as $col) {
            $head[] = $col['name'];
        }
        fputcsv($this->stream, $head, ';');
    }

    private function writeRow(array $row): void
    {
        $out = [];
        foreach ($this->mapping as $col) {
            $raw = $row[$col['column']] ?? null;
            $fn = $col['function'] ?? null;
            $out[] = ($fn !== null && is_callable([ValueConverter::class, $fn]))
                ? ValueConverter::{$fn}($raw)
                : (string) ($raw ?? '');
        }
        fputcsv($this->stream, $out, ';');
    }
}

final class ValueConverter
{
    public static function idate(mixed $v): string
    {
        $t = (int) $v;
        return $t <= 0 ? '—' : date('d.m.Y', $t);
    }

    public static function isBool(mixed $v): string
    {
        return ((bool) $v) ? 'да' : 'нет';
    }
}

Про пароли и персональные данные

Выгружать хеш пароля или чувствительные поля в файл — плохая идея даже для администратора: это расширяет поверхность утечки. Оставляйте в CSV только то, что реально нужно процессу; остальное вырезайте на уровне SQL или через карту колонок.

Кратко

  • Карта колонок отвязывает запрос к данным от заголовков и форматирования.
  • Батчи и поток в php://output сохраняют память на больших выборках.
  • Для Excel и UTF‑8 используйте BOM и корректный Content-Type.
  • Не включайте в выгрузку секреты без крайней необходимости.

Не хотите копаться сами?

Починю за 1-3 дня. Без предоплаты — оплата по результату.

15+ лет опыта с 1С-Битрикс · Без предоплаты · 7 дней гарантии