League CSV: импорт и экспорт CSV в PHP — мастерская и рабочие примеры
Разбираем CSV на понятных примерах: колонки, кодировки, ошибки, безопасный экспорт и настоящий импорт в SQLite. Живая мастерская, готовый PHP-проект и размещение на Linux VPS.

League CSV помогает читать, проверять и создавать CSV в PHP: импортировать контакты, обновлять каталог, выгружать отчёты и передавать данные между сервисами. Библиотека работает без фреймворка, подключается через Composer и снимает рутинную работу с записями, заголовками, разделителями, BOM и форматированием экспорта.
В реальном проекте задача редко заканчивается чтением файла. Нужно понять, где колонка с кодом, почему сумма приехала с запятой, что делать с повторными строками и как сообщить человеку об ошибке до изменения базы. Поэтому здесь есть полноценный путь: живой предпросмотр, сопоставление колонок, проверка правил, подтверждённый импорт в SQLite и обратная выгрузка. Отдельно разберём большие потоки и размещение приватного инструмента на Linux VPS.
Где CSV полезен и какой результат вы получите
CSV удобен там, где одной системе нужно отдать таблицу другой системе или человеку. Менеджер выгружает клиентов из прежней CRM, поставщик присылает остатки, оператор собирает список заявок, бухгалтерия принимает подготовленный реестр. Формат можно просмотреть текстовым редактором и обработать почти на любом языке. При этом договорённость о колонках и их смысле важнее расширения файла: две таблицы .csv вполне могут иметь несовместимые кодировки и правила чисел.
| Задача | Что делает приложение | Полезный результат |
|---|---|---|
| Перенос контактов | Сопоставляет код, имя, email и сумму | Показывает ошибки до записи в базу |
| Каталог поставщика | Проверяет артикулы и обязательные поля | Обновляет существующие позиции по устойчивому ключу |
| Выгрузка отчёта | Создаёт CSV с выбранным разделителем | Получатель открывает ожидаемые колонки и кириллицу |
| Данные из старой системы | Преобразует известную Windows-1251 в UTF-8 | Текст читается одинаково в PHP и интерфейсе |
| Регулярный обмен | Учитывает повторную передачу набора | Повтор запроса не создаёт дублей |
| Большой экспорт | Читает и пишет записи последовательно | Не собирает огромный массив в памяти |
CSV не хранит привычное оформление электронной таблицы: стили ячеек, несколько листов, картинки и надёжную информацию о типах. Для сложной книги нужен другой формат. Но для обмена плоскими наборами CSV часто проще: его можно сформировать из базы, проверить автоматическим тестом и передать партнёру без установки офисного приложения на сервер.
В нашем примере модель специально небольшая: code, name, email и amount. Код остаётся строкой, поэтому 001 не превращается в 1. Сумма хранится десятичной строкой с двумя знаками. Email проверяется по формату, без попытки отправлять письма. Такой каркас можно приспособить под товары, заявки или справочник, заменив правила и бизнес-ключ; сами этапы обработки сохраняются.
League CSV, fgetcsv и explode: что выбрать
PHP уже умеет разбирать CSV через fgetcsv и записывать его через fputcsv. Для небольшого скрипта с известным форматом этого достаточно. League CSV использует возможности PHP и предоставляет более цельный интерфейс: Reader, Writer, работу с заголовком, форматтеры и дополнительные средства преобразования. Выигрыш заметен, когда обмен становится частью приложения, а одинаковые настройки нужны в импорте, экспорте и тестах.
Обычный explode по запятой не учитывает кавычки. Название «Маяк, Север» разорвётся на две колонки, хотя запятая принадлежит названию. Разделение текста по переносам тоже недостаточно: один комментарий может занимать несколько физических строк. Вместо исправления частных случаев вручную лучше сразу использовать CSV-парсер, а свои усилия направить на проверку смысла данных.
| Подход | Когда подходит | Что остаётся вашему коду |
|---|---|---|
| fgetcsv / fputcsv | Небольшой известный поток | Заголовки, настройки, ошибки и правила предметной области |
| League CSV | Повторяемый импорт и экспорт в PHP | Авторизация, mapping, бизнес-валидация и транзакции |
| Papa Parse | Предпросмотр CSV в браузере | Серверная проверка и реальное сохранение |
| explode | Простая строка без CSV-синтаксиса | Для произвольного CSV непригоден |
Библиотека не решает за вас, обновлять ли существующий контакт или создавать новый, принимать ли отрицательную сумму и пропускать ли ошибочные записи. Эти решения должны быть видимыми. В мастерской используется строгий вариант: пока есть ошибки, нельзя скачать проверенный экспорт или подтвердить импорт. Пользователь видит причину рядом с записью, а не получает неопределённое «файл повреждён».
Источник: PHP: fgetcsv, кавычки и параметр escape.
Мини-мастерская: восемь ситуаций и собственный CSV
Начните с выпадающего списка ситуаций. В первом примере обычные контакты, во втором — разделитель «;» и десятичная запятая, в третьем — кавычки и перенос внутри имени. Затем попробуйте иной порядок колонок, ошибки, формулы, повторные коды и файл без заголовка с TAB. Меняйте текст и нажимайте «Проверить CSV»: результат не остаётся незаметно устаревшим после редактирования.
Сопоставление задаёт, какая колонка источника станет кодом, именем, email и суммой. В четвёртом примере почта стоит первой, а артикул — третьим. Выберите правильные соответствия и посмотрите, как таблица приводится к модели приложения. Одну исходную колонку нельзя назначить двум полям. Заголовки должны быть непустыми и уникальными, а число полей каждой записи — соответствовать таблице.
Открыть мастерскую на весь экран ↗Скачать PHP-проект и исходники ↓
Можно открыть свой небольшой CSV с компьютера. Выбранная кодировка применяется к байтам файла; при её изменении файл перечитывается. Если вы вручную редактируете текст, связь с первоначальным файлом сбрасывается. Это помогает избежать ситуации, когда пользователь исправил строку, а последующее действие неожиданно вернуло старый вариант. Ограничение интерфейса — 20 000 байт и 100 записей; для большой обработки ниже есть отдельный потоковый пример.
После успешной проверки доступны исходный CSV, нормализованный безопасный экспорт и PHP-пример с текущими настройками. PHP-файл нужно сохранить в корень скачанного csv-project: он использует класс CsvService из архива, повторно разбирает input.csv и печатает проверенный результат. Это конкретный код для вашей конфигурации, а не декоративный фрагмент. В тестах все восемь сгенерированных вариантов действительно запускались в PHP.
Источник: Papa Parse: браузерный разбор, ошибки и экспорт.
Установка League CSV через Composer
Для использования библиотеки не требуется Laravel или Symfony. Достаточно совместимого PHP и Composer. Если управление зависимостями пока незнакомо, пригодится руководство по Composer с командами и примерами. В готовом архиве уже лежит composer.lock: повторное развёртывание устанавливает тот же проверенный пакет.
composer require league/csv:9.29.0
composer check-platform-reqs
На 7 ноября 2026 года проверена стабильная League CSV 9.29.0, опубликованная 28 октября. Пакет требует PHP 8.2+ и ext-filter. Наше приложение дополнительно использует iconv и PDO SQLite; образ закреплён на PHP 8.5.12. На сервере нельзя обходить несовместимость через ignore-platform-reqs: так установка может выглядеть успешной, а первый пользовательский запрос завершится ошибкой.
Composer install восстанавливает версии из lock-файла. Composer update выбирает новый набор и меняет lock. Обновляйте зависимости в рабочей копии, проверяйте тесты и только затем переносите релиз. В ZIP не включён vendor: библиотека устанавливается штатно при сборке. Собственный код и League CSV распространяются по MIT; тексты лицензий находятся в архиве.
Источник: League CSV 9.29.0.
Источник: Требования и версии пакета.
Источник: Composer: install, update и lock-файл.
Первый запуск в Windows PowerShell
Установите Docker Desktop и запустите Linux-контейнеры. В выводе docker version должны быть Client и Server. Отдельные PHP, Apache, Composer и SQLite на Windows для этого варианта не нужны. Команды ниже создают новый csv-lab; если архив уже распакован, откройте его csv-project и начинайте с проверки Compose.
New-Item -ItemType Directory -Force C:\Projects | Out-Null
Set-Location C:\Projects
Invoke-WebRequest https://ae-nekrasov.ru/wp-content/plugins/aen-csv-workshop/csv-project.zip -OutFile csv-project.zip
Expand-Archive csv-project.zip -DestinationPath csv-lab
Set-Location .\csv-lab\csv-project
docker compose config --quiet
docker compose up -d --build --wait
docker compose ps
Invoke-WebRequest http://127.0.0.1:8102/health.php | Select-Object -ExpandProperty Content
Откройте http://127.0.0.1:8102/. Вы увидите ту же понятную форму, но её результат уже выдаёт PHP. Появится дополнительный блок «Реальный импорт в SQLite». Проверьте первый пример, подтвердите импорт и обновите страницу: записи останутся. Порт привязан к loopback и недоступен другим компьютерам сети. Если он занят, замените только внешний 8102 в локальном Compose и используйте новый адрес.
Первый запуск в Linux Bash
На Linux нужны Docker Engine и Compose plugin. Полная установка для свежей Ubuntu 24.04 приведена ниже и в deploy/README.md. Для уже подготовленной машины используйте самостоятельный блок команд. При отсутствии доступа к Docker выполняйте его команды через sudo; каталог проекта в домашней папке создавайте обычным пользователем.
mkdir -p ~/projects/csv-lab
cd ~/projects/csv-lab
curl -fL https://ae-nekrasov.ru/wp-content/plugins/aen-csv-workshop/csv-project.zip -o csv-project.zip
unzip -n csv-project.zip
cd csv-project
docker compose config --quiet
docker compose up -d --build --wait
docker compose ps
curl --fail http://127.0.0.1:8102/health.php
Результат такой же: healthy-контейнер, ответ ok и реальный PHP-интерфейс. Если предварительный запуск выполнен на удалённом VPS, используйте SSH-туннель с рабочего компьютера: ssh -L 8102:127.0.0.1:8102 user@server. Для постоянной работы на домене есть другой Compose, с HTTPS и авторизацией. Не расширяйте привязку локальной формы на весь интернет.
Задача 1. Прочитать контакты и сохранить смысл значений
Самый короткий сценарий — открыть известный файл, указать правила CSV и пройти по записям. Reader::from принимает путь и режим чтения. setHeaderOffset(0) превращает первую запись в имена полей, поэтому дальше код обращается к email по имени. Явный setEscape с пустой строкой важен для современного PHP и привычного удвоения кавычек внутри CSV.
require __DIR__ . '/vendor/autoload.php';
use League\Csv\Reader;
$reader = Reader::from(__DIR__ . '/samples/contacts.csv', 'r');
$reader->setDelimiter(',');
$reader->setEscape('');
$reader->setHeaderOffset(0);
foreach ($reader->getRecords() as $record) {
echo $record['code'], ' → ', $record['email'], PHP_EOL;
}
Для комплектного файла результат — две связи: 001 с anna@example.test и 002 с ilya@example.test. Ведущие нули остаются частью строки. Это важно для артикулов, внешних идентификаторов, телефонов и почтовых индексов. Не включайте автоматическое превращение всех похожих значений в числа: восстановить потерянные нули без знания исходной модели нельзя.
Удобный заголовок не заменяет контроль структуры. League CSV при работе с header может дополнить недостающие поля null и отбросить лишние. Для строгого импорта такая нормализация слишком незаметна. Поэтому CsvService сначала читает сырые записи, сравнивает их ширину с заголовком и только затем применяет mapping. Дополнительная пятая колонка становится видимой ошибкой, а не молча потерянными данными.
Источник: Reader: заголовки и нормализация записей.
Задача 2. Разделитель, кавычки и многострочные поля
У CSV есть диалекты. Один экспорт использует запятую, другой — точку с запятой, третий — табуляцию. Десятичная запятая не обязана совпадать с разделителем колонок: в европейской выгрузке удобно разделять поля точкой с запятой и писать сумму 1490,50. Эти два параметра надо задавать независимо. Выбор только одного из них часто приводит к таблице, которая внешне открылась, но содержит неверные значения.
code;name;email;amount
003;Елена Морозова;elena@example.test;1490,50
В мастерской включите «Десятичная запятая». Сумма станет 1490.50 после проверки, а не после приблизительного преобразования в float. Наше правило принимает до двух дробных знаков и неотрицательное значение. Если в вашей задаче нужны возвраты или отрицательные остатки, измените правило осознанно вместе с тестами; не убирайте проверку только ради одного спорного файла.
Название с разделителем заключают в двойные кавычки, а кавычку внутри удваивают. Перенос строки внутри заключённого поля также является содержимым поля. Поэтому номер CSV-записи и номер строки текстового редактора иногда различаются. Для оператора важнее показать распознанные поля и конкретную причину отказа, чем сослаться на физическую строку, которую он не сможет сопоставить с таблицей.
code,name,email,amount
005,"ООО ""Маяк, Север""",team@example.test,1500
006,"Первая строка
Вторая строка",two@example.test,20
Наш предварительный контроль отклоняет незакрытую кавычку и неожиданный текст после закрывающей кавычки. Собственно извлечение записей выполняет League CSV. Такое разделение полезно: библиотека отвечает за CSV, приложение — за выбранный строгий договор обмена. Если источник использует старый backslash-escape, сначала исследуйте его формат; смена escape на пустую строку без проверки может изменить трактовку старых файлов.
Источник: Разделитель, enclosure и escape.
Задача 3. Сопоставить чужие колонки и объяснить ошибки
Импорт не должен требовать от каждого поставщика одинаковых названий. В одном файле код называется «Артикул», в другом «ID клиента». Пользователь может один раз сопоставить колонки с полями приложения. Но соответствие надо сохранить вместе с правилами: одинаковые байты при другом mapping означают другие данные. Именно поэтому настройки участвуют в fingerprint нашего импорта.
use Aen\CsvLab\CsvService;
$preview = CsvService::preview($bytes, [
'delimiter' => ';',
'header' => true,
'decimalComma' => false,
'mapping' => [2, 3, 0, 1], // code, name, email, amount
]);
foreach ($preview['rows'] as $row) {
if ($row['errors']) {
echo $row['recordNumber'], ': ', implode('; ', $row['errors']), PHP_EOL;
}
}
В проекте проверяются обязательность и длина имени, формат email, допустимый код, формат суммы, ширина записи и повторы кодов внутри одного файла. Ошибки собираются по строкам, а не обрывают всё сообщение на первом некорректном email. При этом commit остаётся недоступен, пока есть хотя бы одна ошибка: пользователь понимает, что частично принятых контактов не появилось.
Семантика проверки важна. Формально правильный email не подтверждает существование почтового ящика. Правильно записанная сумма не доказывает, что она совпадает с заказом. Код уникален только в выбранной области приложения. Для импорта из внешнего сервиса дополнительно проверяйте принадлежность данных, допустимость изменений и права текущего пользователя. В многоарендной системе ключ обычно включает организацию, а не только значение из файла.
Удобный интерфейс позволяет вернуться к исходному тексту, исправить данные и повторить проверку без потери контекста. В нашем варианте изменение текста сбрасывает прежний результат и запрещает скачивать устаревший PHP-код. Сообщение «данные изменились» полезнее, чем зелёный статус от предыдущего файла. На сервере действует то же правило: fingerprint повторно сравнивается перед commit.
Задача 4. UTF-8, BOM и Windows-1251
Кодировка описывает, как байты превращаются в символы. BOM — отдельная последовательность в начале файла, а не другая версия CSV. UTF-8 может быть как с BOM, так и без него. Некоторые потребители лучше распознают кириллицу при наличии BOM, другие ожидают чистый UTF-8. Поэтому решение выбирается под конкретного получателя и проверяется на его способе открытия файла.
use League\Csv\{Reader, Bom};
$reader = Reader::from(__DIR__ . '/samples/contacts-bom.csv', 'r');
$reader->setEscape('');
$reader->setHeaderOffset(0);
echo $reader->getInputBOM() === Bom::Utf8->value ? 'UTF-8 BOM' : 'Без UTF-8 BOM';
// Reader не включает распознанный BOM в первое имя заголовка.
Если источник точно использует Windows-1251, преобразуйте известную кодировку в UTF-8 до основной проверки. Автоматическое угадывание не является доказательством: ASCII-подмножество выглядит одинаково в нескольких кодировках, а повреждённый текст может пройти эвристику. В интерфейсе кодировка выбирается явно, а неверный UTF-8 вызывает понятный отказ. UTF-16 этим небольшим стендом не заявляется поддержанным.
$utf8 = iconv('Windows-1251', 'UTF-8', $originalBytes);
if ($utf8 === false) {
throw new RuntimeException('Не удалось преобразовать кодировку');
}
$preview = CsvService::preview($utf8);
В samples лежат реальные бинарные варианты одного набора: обычный UTF-8, UTF-8 с BOM и Windows-1251. Проверьте их CLI-командой ниже: PHP действительно читает исходные байты. Это отличается от выбора файла в браузере, где TextDecoder сначала делает Unicode-текст. Для больших файлов используйте потоковый фильтр преобразования и известную исходную кодировку, сохраняя контроль ошибок и границу доступного ресурса.
docker compose exec -T web php bin/preview.php samples/contacts-cp1251.csv Windows-1251
Источник: BOM в League CSV.
Источник: PHP iconv: преобразование кодировок.
Задача 5. Экспорт, который сохраняет колонки и ограничивает формулы
Writer берёт массив полей и сам добавляет нужные кавычки. Не собирайте CSV конкатенацией через запятую: рано или поздно в имени появится разделитель, кавычка или перенос. Перед выгрузкой определите порядок колонок, разделитель, кодировку и окончания записей. Для небольшого экспорта проекта используются UTF-8 BOM и CRLF; файл можно скачать через явный Content-Disposition.
use League\Csv\{Writer, Bom, EscapeFormula};
$writer = Writer::fromString();
$writer->setDelimiter(';');
$writer->setEscape('');
$writer->setEndOfLine("\r\n");
$writer->setOutputBOM(Bom::Utf8);
$guard = new EscapeFormula("'", ["\n"]);
$writer->addFormatter($guard->escapeRecord(...));
$writer->insertOne(['code', 'name', 'email', 'amount']);
$writer->insertOne(['013', '=1+1', 'formula@example.test', '5.00']);
echo $writer->toString();
Значение, начинающееся с =, +, -, @ или некоторых управляющих символов, офисная программа может интерпретировать как формулу. CSV-парсер не выполняет её сам, но опасность появляется при открытии выгрузки. В проекте EscapeFormula добавляет апостроф; дополнительный перевод строки включён в набор контролируемых начальных символов. В мастерской можно увидеть преобразование на безвредном примере =1+1.
Это не универсальная защита для всех потребителей. Форматтер меняет значение, а конкретная таблица может дополнительно преобразовать его при повторном сохранении или импорте. Проверяйте свой сценарий открытия, не снимайте экранирование автоматически и не полагайтесь на одни кавычки CSV. Для машинного обмена иногда нужен отдельный экспорт исходных значений с другим договором доступа, а не один файл для любых получателей.
Похожая граница существует и у HTML-предпросмотра. Строки CSV выводятся через textContent, поэтому имя, похожее на HTML, остаётся текстом. Защита таблицы от формул и защита веб-страницы от HTML-инъекции — разные задачи. Обе должны находиться на выходе в соответствующий формат. Серверный экспорт заново использует League CSV и не принимает готовый файл, созданный клиентским JavaScript, как доверенный результат.
Источник: Writer: вставка записей и окончания строк.
Источник: EscapeFormula и границы защиты.
Задача 6. Предпросмотр и подтверждение импорта в SQLite
Главный результат приложения — контролируемое изменение базы. Первый запрос возвращает preview и fingerprint, не выполняя запись. Пользователь видит, что будет принято, и нажимает отдельную кнопку подтверждения. Второй запрос передаёт CSV, настройки, CSRF и отпечаток. PHP заново разбирает исходные данные и проверяет, что они соответствуют последнему просмотру этой сессии.
После проверки ImportStore начинает транзакцию SQLite. Код контакта является первичным ключом: новая запись добавляется, существующий код обновляет имя, email и сумму. Затем фиксируется fingerprint набора. Если сохранение любой строки завершается ошибкой, откатываются и контакты, и запись об импорте. В тесте специально создаётся отказ на второй вставке: первая тоже не остаётся в базе.
use Aen\CsvLab\{CsvService, ImportStore};
// В web-контроллере сначала проверены пользователь, CSRF и fingerprint preview.
$preview = CsvService::preview($csv, $config);
if ($preview['invalid'] > 0) {
throw new RuntimeException('Исправьте все строки');
}
$store = new ImportStore('/var/lib/csv-lab/contacts.sqlite');
$result = $store->import($preview);
База находится вне webroot. Публичный каталог содержит только интерфейс и API; vendor, исходники классов, lock и SQLite нельзя скачать по URL. Для SQLite важно право записи в каталог, потому что рядом могут создаваться служебные журналы. В контейнере каталог принадлежит www-data. Если переносите решение в свой FPM pool, используйте его фактического пользователя и ограниченные права, а не универсальный chmod 777.
В этой небольшой модели выбран импорт целиком. Для больших процессов бывает удобен пакетный режим с отчётом по отклонённым строкам, но тогда его частичность должна быть явной: сколько строк принято, какие пропущены и как повторить только ошибки. Нельзя обещать полную атомарность, сохраняя каждую запись отдельным commit. Понятный договор для пользователя важнее формального наличия кнопки «Импортировать».
Задача 7. Повторы, устаревший предпросмотр и ошибки запроса
Пользователь может дважды нажать кнопку, сеть может потерять ответ, а клиент — повторить запрос. Наш fingerprint зависит от нормализованных записей и настроек. Уже сохранённый набор возвращает сообщение о повторе и не создаёт новые контакты. Смена данных даёт новый отпечаток и обновляет существующие бизнес-ключи. Это два разных уровня: идентичность операции и идентичность контакта.
У такой политики есть понятная особенность: повтор старого уже принятого файла после других изменений не восстанавливает старые значения. Он считается уже выполненной операцией. Для настоящего повторного применения понадобился бы отдельный пользовательский процесс с новым идентификатором, правами и подтверждением. В учебной форме намеренно нет скрытого «форсировать», которое могло бы незаметно перезаписать более свежие данные.
| Ответ | Что произошло | Что делать |
|---|---|---|
| 403 | CSRF отсутствует или сессия изменилась | Обновить страницу и проверить данные заново |
| 409 | Preview устарел или CSV изменился | Повторить предварительную проверку |
| 422 | Структура или данные не проходят правила | Исправить показанные причины |
| 503 | Операция сервера не завершилась штатно | Проверить журнал по коду ошибки и текущую базу |
Таймаут ответа не доказывает, что сервер ничего не сохранил. Интерфейс поэтому предлагает сначала обновить список. Повтор того же набора безопаснее благодаря учёту fingerprint, но внешние действия — письма, списания, вызовы партнёра — этот пример не выполняет. Если они появятся, потребуются собственные ключи идемпотентности и согласование с транзакцией, например через outbox.
В журнал не записывается полный CSV или email при каждом сбое. Сохраняются технический идентификатор и класс ошибки, а пользователь получает понятное сообщение. Для поддержки такого инструмента полезнее знать стадию, число записей и версию правил, чем без необходимости копировать персональные данные в логи. Доступ к самим исходным файлам и резервным копиям должен соответствовать содержимому.
Задача 8. Большой экспорт без массива всех записей
Лимит веб-мастерской не является пределом League CSV. Для большого набора используйте итератор или генератор и записывайте строки по мере получения. Если сначала вызвать fetchAll, iterator_to_array или toString для огромного источника, преимущество потока исчезнет: данные снова окажутся целиком в памяти. Проверять нужно всю цепочку, включая драйвер базы и HTTP-буферизацию.
<?php
declare(strict_types=1);
require dirname(__DIR__).'/vendor/autoload.php';
use League\Csv\{Writer, EscapeFormula};
// 50,000 synthetic records from a generator: no array containing the whole dataset.
function rows(int $count): Generator {for($i=1;$i<=$count;$i++)yield [sprintf('C%06d',$i),'Учебный контакт '.$i,'contact'.$i.'@example.test','1250.00'];}
$count=filter_var($argv[1]??50000,FILTER_VALIDATE_INT);if($count===false||$count<1||$count>1000000)exit(2);
$stream=fopen('php://output','w');$writer=Writer::from($stream);$writer->setEscape('');
$guard=new EscapeFormula("'",["\n"]);$writer->addFormatter($guard->escapeRecord(...));
$writer->insertOne(['code','name','email','amount']);$writer->insertAll(rows($count));
fwrite(STDERR,json_encode(['rows'=>$count,'peak_memory_bytes'=>memory_get_peak_usage(true)]).PHP_EOL);
Комплектный CLI генерирует 50 000 синтетических контактов и пишет непосредственно в php://output. При проведённом запуске получено 50 001 CSV-запись с учётом заголовка, около 3,98 МБ данных и 2 097 152 байта пикового потребления PHP. Это измерение данного генератора в нашем контейнере, а не обещание для любого источника, размера поля или SQL-запроса. Прямой поток использует LF; небольшой экспорт интерфейса отдельно настроен на CRLF.
docker compose exec -T web sh -c 'php bin/stream-export.php 50000 > /tmp/large.csv'
docker compose cp web:/tmp/large.csv ./large.csv
Сохранение сначала внутри контейнера и последующий docker compose cp подходят и для Bash, и для PowerShell: оболочка не перекодирует CSV при перенаправлении. Для многоминутного отчёта обычно удобнее фоновая задача с файлом результата, статусом и сроком хранения, чем открытый HTTP-запрос на всё время обработки. Пользователь получает понятный прогресс и ссылку после завершения, а приложение может повторить неуспешную задачу по правилам.
Источник: Вывод CSV, потоки и ограничения seekable-ресурсов.
Повторяем HTTP-проверку из консоли
Для автоматизации полезно пройти реальный API, сохранив cookie сессии и CSRF. Ниже два полных варианта одного действия: получить сессию и отправить samples/contacts.csv на preview. Они не изменяют базу. В PowerShell JSON передаётся как явные байты UTF-8, чтобы кириллица не зависела от кодировки оболочки.
$session = Invoke-RestMethod http://127.0.0.1:8102/api.php?action=session -SessionVariable csvSession
$csv = Get-Content samples/contacts.csv -Raw -Encoding utf8
$body = @{ csrf=$session.csrf; csv=$csv; config=@{delimiter=','; header=$true; mapping=@(0,1,2,3)} } | ConvertTo-Json -Depth 5
$utf8 = [Text.Encoding]::UTF8.GetBytes($body)
Invoke-RestMethod http://127.0.0.1:8102/api.php?action=preview -Method Post -WebSession $csvSession -ContentType 'application/json; charset=utf-8' -Body $utf8
mkdir -p .runtime
curl --fail -c .runtime/cookies.txt 'http://127.0.0.1:8102/api.php?action=session' -o .runtime/session.json
python3 - <<'PY'
from pathlib import Path
import json
session=json.loads(Path('.runtime/session.json').read_text())
body={'csrf':session['csrf'],'csv':Path('samples/contacts.csv').read_text(encoding='utf8'),'config':{'delimiter':',','header':True,'mapping':[0,1,2,3]}}
Path('.runtime/request.json').write_text(json.dumps(body,ensure_ascii=False),encoding='utf8')
PY
curl --fail -b .runtime/cookies.txt -H 'Content-Type: application/json' --data-binary @.runtime/request.json 'http://127.0.0.1:8102/api.php?action=preview'
Ожидайте engine с League CSV и PHP, valid равный 2 и invalid равный 0. На сервере нельзя заменять такую проверку результатом браузерного парсера. В tests/check_http.py дополнительно проверяются отсутствующий CSRF, изменённый CSV перед commit, повтор подтверждения, формулы, BOM, Windows-1251, лимит размера и недоступность приватных файлов по URL.
Linux VPS: размещаем закрытый инструмент на своём домене
Для практического использования в архив добавлен deploy/compose.server.yaml. Он запускает PHP с постоянным томом SQLite и Caddy на 80/443. Web-контейнер не публикует отдельный порт. Все запросы, включая API и экспорт, проходят через общий пароль. Это подходящий первый этап для личного или внутреннего инструмента; для клиентского сервиса нужны аккаунты, роли и собственные правила доступа.
На новой Ubuntu 24.04 сначала установите Docker из официального репозитория. Следующий блок рассчитан именно на свежий сервер. Если Docker или другой reverse proxy уже работают, сохраните их конфигурацию и интегрируйте приложение в существующее окружение. Перед работой проверьте версию ОС и занятые порты. Не останавливайте чужой сайт ради учебного примера.
sudo apt-get update
sudo apt-get install -y ca-certificates curl unzip python3
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
docker_arch=$(dpkg --print-architecture)
sudo tee /etc/apt/sources.list.d/docker.sources >/dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: noble
Components: stable
Architectures: $docker_arch
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker
sudo docker compose version
Создайте A-запись своего домена, например csv.your-domain.tld. AAAA добавляйте только при действительно доступном IPv6. Разрешите TCP 80/443 на сетевом экране хостинга. Учитывайте правила Docker для firewall: опубликованный контейнерный порт может обходить привычное правило UFW. Порт 8102 на публичном сервере не нужен. Затем разместите код отдельным релизом и вынесите env за пределы webroot.
sudo install -d -m 0755 /opt/aen-csv/releases/v1
sudo curl -fL https://ae-nekrasov.ru/wp-content/plugins/aen-csv-workshop/csv-project.zip -o /opt/aen-csv/releases/v1/source.zip
sudo unzip /opt/aen-csv/releases/v1/source.zip -d /opt/aen-csv/releases/v1
sudo ln -s /opt/aen-csv/releases/v1/csv-project /opt/aen-csv/current
cd /opt/aen-csv/current
sha256sum /opt/aen-csv/releases/v1/source.zip
sudo install -d -m 0700 /etc/aen-csv
sudo install -m 0600 deploy/server.env.example /etc/aen-csv/server.env
sudo docker run --rm -it caddy:2.11.9-alpine caddy hash-password
sudoedit /etc/aen-csv/server.env
В server.env укажите домен без протокола, имя reader и bcrypt-хеш, полученный интерактивной командой. Сохраните одинарные кавычки вокруг хеша, чтобы символы $ не интерпретировались Compose. Каталог секретов имеет 0700, файл — 0600. Папка релиза читается контейнером; записываемая база находится отдельно. Не перезаписывайте заполненный env шаблоном при повторном запуске.
cd /opt/aen-csv/current
sudo docker compose --env-file /etc/aen-csv/server.env -f deploy/compose.server.yaml config --quiet
sudo docker compose --env-file /etc/aen-csv/server.env -f deploy/compose.server.yaml build web
sudo docker compose --env-file /etc/aen-csv/server.env -f deploy/compose.server.yaml run --rm --no-deps caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
sudo docker compose --env-file /etc/aen-csv/server.env -f deploy/compose.server.yaml up -d --no-build --wait
sudo docker compose --env-file /etc/aen-csv/server.env -f deploy/compose.server.yaml ps
curl -I http://csv.your-domain.tld/
curl -I https://csv.your-domain.tld/
curl --fail --user reader https://csv.your-domain.tld/health.php
Должны появиться два healthy-сервиса, перенаправление HTTP на HTTPS, отказ 401 без пароля и ok после авторизации. В браузере выполните полный рабочий цикл: preview, подтверждение, обновление списка и экспорт. Это проверяет полезный результат, а не только наличие контейнера. При ошибке сертификата проверяйте DNS и журнал; отключать проверку TLS для получения зелёного curl не требуется.
Серверный YAML самостоятельный: его не надо объединять с локальным файлом. Состояние Caddy сохраняется в named volume, а SQLite переживает пересоздание web-контейнера. PHP получает корректный признак HTTPS от доверенного reverse proxy. Admin API и внутренний health-порт не публикуются. Полная инструкция эксплуатации находится в deploy/README.md, включая подключение классов к существующему PHP/FPM-приложению.
Источник: Docker Engine на Ubuntu.
Источник: Caddy: HTTPS и условия получения сертификата.
Резервная копия, восстановление и обновление
Нельзя считать случайную копию работающего SQLite-файла надёжным backup: приложение может менять базу или использовать журнал. Наш bin/backup.py вызывает VACUUM INTO, получает согласованный снимок, переносит его из контейнера и записывает SHA-256. Серверный вариант также сохраняет env. Готовый backup содержит контакты и учёт выполненных импортов; временный снимок внутри контейнера удаляется после копирования.
sudo python3 bin/backup.py --env /etc/aen-csv/server.env --output /var/backups/aen-csv
python bin/backup.py --output C:\Backups\aen-csv
Каталог получает 0700, файлы 0600; на Windows дополнительно настройте NTFS ACL. В backup не входят код релиза, Docker-образы и сертификатные тома Caddy. Эти границы указаны в manifest и README: сохраняйте исходный ZIP и инфраструктурное состояние отдельно. Копия на том же диске не защищает от потери самого сервера, поэтому переносите проверенные копии в независимое закрытое хранилище.
sudo python3 bin/restore.py --backup /var/backups/aen-csv/20261107T120000000000Z --volume aen-csv-restore-check1
Подставьте точный каталог, напечатанный backup. Восстановление проверяет checksum и целостность SQLite, создаёт только новый том и выставляет владельца для PHP. Существующий том не перезаписывается, сайт автоматически не запускается. Для переключения остановите запись, сохраните активный env, задайте CSV_VOLUME с новым именем и выполните up с прежним DEPLOY_PROJECT. До приёмки старый том остаётся доступным для отката.
Обновление кода оформляйте отдельным релизом v2: новый каталог, отдельный тег образа, проверка конфигурации и затем запуск. Сохраняйте старый env и образ. Возврат к v1 не откатывает изменения данных, а восстановление backup возвращает состояние на момент снимка. Эти два действия решают разные задачи. Полные команды обновления, восстановления, переключения и возврата приведены рядом с исходниками, чтобы ими можно было воспользоваться без поиска по переписке.
Источник: SQLite VACUUM INTO и согласованный снимок.
Что проверено и что проверять в своём проекте
PHP-тесты проверяют ведущие нули, BOM, реальные байты Windows-1251, кавычки, переносы, mapping, ошибочные записи, формулы, повтор импорта и откат транзакции. HTTP-проверка проходит через Apache и сессию. Сгенерированные PHP-файлы для всех ситуаций запускаются отдельно. Большой CSV проверен по числу записей и измеренной памяти. Backup восстановлен в новый том, после чего проверены данные, целостность и чтение от пользователя PHP.
docker compose exec -T web php tests/run.php
python tests/check_http.py
node tests/workshop.mjs
docker compose logs --tail 50 web
В Linux Python может называться python3. HTTP-тест создаёт только учебные контакты и использует фиксированный loopback-адрес. Исходный PHP-стенд проверен в Linux-контейнерах Docker Desktop на Windows. Это не выдаётся за испытание всех Linux-дистрибутивов, настоящего VPS, публичного ACME или конкретного Excel. Для собственного получателя дополнительно проверьте открытие выгрузки, ведущие нули, кодировку и обработку апострофов.
Останавливайте стенд через docker compose stop; запускайте существующие контейнеры через start –wait. Обычный down сохраняет named volume. Флаг -v удаляет базу и не нужен для обычного завершения. После обновления зависимостей повторяйте проверки того файла, который раньше выявил проблему, и соседнего успешного сценария. Так импорт остаётся понятной, проверяемой частью приложения.
Исходники и применение в своём сервисе
Начните с одного формата и одного полезного результата. Опишите колонки, разделитель, кодировку, бизнес-ключ и политику ошибок. Затем перенесите проверку и сохранение в свой контроллер, сохранив отдельные этапы preview и commit. Когда объёмы вырастут, добавьте фоновые задачи, прогресс, срок хранения файлов и мониторинг. Логика интерфейса при этом останется знакомой пользователю: сначала понять, что будет изменено, затем подтвердить действие.
Открыть мастерскую на весь экран ↗Скачать PHP-проект и исходники ↓
В архив входят PHP-классы, интерфейс Bootstrap 5, Docker Compose, файлы разных кодировок, тесты, лицензии и подробные инструкции Windows/Linux. Для хранения больших выгрузок можно дополнить проект материалом о Flysystem, для отправки готового отчёта — Symfony Mailer, а для конфигурации собственного проекта — Composer.
Источник: Официальная документация League CSV.
Источник: Исходный код и лицензия библиотеки.


