FormData отправляется, но файл не приходит на сервер: как проверить запрос
Пользователь выбрал документ, нажал кнопку и увидел сообщение об успехе, но в заявке нет вложения. Если через FormData файл не отправляется или не приходит в обработчик, проверять нужно несколько границ: выбор файла, сборку данных, HTTP-запрос, разбор multipart и постоянное хранение. Исправление зависит от того, на какой границе исчезли байты или потерялась связь с заявкой.
Разберём последовательность для разработчика и владельца сайта: какие доказательства собрать, почему имя поля важнее его внешнего вида, откуда берётся boundary и как читать результат загрузки в PHP. Все примеры ниже учебные; адрес обработчика, разрешённые форматы и правила доступа нужно брать из своего проекта.
1. Определите, что именно означает «файл не пришёл»
Начните с одного небольшого тестового документа без персональных данных. Запишите время попытки, страницу, имя поля, размер файла и идентификатор запроса, если приложение его выдаёт. Затем сопоставьте браузер, журнал обработчика и запись вложения. Скриншот зелёного уведомления показывает поведение интерфейса, но не подтверждает сохранение.
| Наблюдение | Следующая проверка |
|---|---|
| В FormData отсутствует файловое поле | Выбор файла, name, disabled и момент сборки |
| Поле есть, но содержит строку | Передан File или значение input.value |
| В запросе видна файловая часть, обработчик её не получает | Маршрут, multipart-парсер, имя поля и ограничения |
| Сервер получил временный файл, вложение недоступно | Перенос, права хранения и привязка к объекту |
Не меняйте одновременно JavaScript, PHP и настройки прокси. Одна проверяемая гипотеза на попытку позволяет понять результат. Если проблема возникает только на определённой странице или при повторной отправке, сохраните это различие: оно помогает обнаружить другую форму, старый обработчик события или накопленные значения.
Проверяемый документ удобно снабдить уникальной тестовой строкой. Тогда после загрузки можно открыть именно сохранённое вложение и отличить его от старого файла с таким же именем. Одно совпадение имени слабее такой проверки: приложение могло повторно показать прежнюю запись, сохранив внешнее впечатление успешной отправки.
2. Проверьте объект File, а не строку с именем
Согласно документации файлового input, выбранные файлы находятся в свойстве files, которое содержит FileList. Первый объект доступен как input.files[0]. Свойство value возвращает строку, часто с C:\fakepath\; это не содержимое файла и не путь, который сервер может открыть на компьютере посетителя.
Перед сборкой запроса проверьте количество выбранных файлов и размер каждого в байтах. Отсутствующий files[0] и выбранный файл размером ноль байт — разные состояния. Второе может быть настоящим пустым файлом. Разрешать его или отклонять нужно по правилам продукта, а не объявлять любую нулевую длину сетевой ошибкой.
Отдельно проверьте порядок действий после выбора. Компонент интерфейса мог пересоздать input, очистить форму или заменить элемент до вызова отправки. Имя документа при этом иногда остаётся в отдельной надписи. Смотрите состояние того DOM-элемента, из которого текущий обработчик действительно читает files, непосредственно перед созданием тела запроса.
3. Согласуйте name, состояние полей и submitter
new FormData(form) собирает подходящие для отправки элементы формы. Для обычного файлового поля нужен name; атрибут id его не заменяет. Отключённые поля не входят в набор, неотмеченные checkbox тоже не отправляют значение. Эти правила описаны в документации конструктора FormData.
Поэтому сначала соберите данные и только затем блокируйте элементы интерфейса от повторного нажатия. Если заранее отключить файловый input или содержащий его fieldset, можно убрать вложение из отправляемого набора. Скрытое через CSS поле и поле с disabled ведут себя по-разному: невидимость сама по себе не исключает значение.
Если сервер выбирает действие по имени нажатой кнопки, одного new FormData(form) недостаточно для включения её значения. В обработчике события submit можно передать event.submitter вторым аргументом. Это должна быть допустимая кнопка отправки, связанная с данной формой; произвольный элемент приводит к исключению. При отсутствии submitter создавайте данные только из формы.
Составьте короткий контракт: поле attachment, один файл, обязательность, допустимые размеры и форматы. Сверьте его с HTML, JavaScript и обработчиком. Такое сопоставление часто полезнее просмотра всей страницы целиком: опечатку между attachment и attachments легко пропустить.
4. Различайте append, set и несколько файлов
append() добавляет ещё одно значение под тем же именем, а set() заменяет все существующие значения этого имени. Описание методов FormData также уточняет: значением может быть строка или Blob, включая File; другие значения преобразуются в строку.
Если файл уже попал в new FormData(form), дополнительный append("attachment", file) создаст повторяющееся поле. Серверный обработчик может ожидать только один объект и отклонить такой запрос либо выбрать одно значение согласно своей реализации. Проверьте data.getAll("attachment"), прежде чем считать ошибкой поведение сервера.
Для единственного заменяемого вложения подходит set(); для коллекции нужен осознанный цикл добавления. Но повторяющееся имя files и имя files[] — разные строки в запросе. Квадратные скобки имеют значение для соглашений конкретного серверного стека, в частности PHP, а не являются универсальной командой браузеру.
Для созданного программно Blob можно задать имя третьим аргументом. Это помогает обработчику получить ожидаемые метаданные, но не превращает произвольные байты в PDF или изображение. Не передавайте вместо объекта строку "[object File]", путь или весь FileList: такие значения не заменяют файл.
Создавайте новый набор данных для каждой самостоятельной отправки. Долгоживущий объект FormData, в который каждый обработчик снова добавляет вложение, способен накопить предыдущие файлы. Проверьте также число зарегистрированных обработчиков: два слушателя одного события могут отправить два разных запроса, и просмотр только последнего исказит картину.
5. Передайте FormData напрямую и оставьте boundary браузеру
Для Fetch API тело запроса должно содержать сам объект FormData. JSON.stringify(data) не сериализует его как multipart и не переносит бинарное содержимое выбранных файлов. Преобразование через обычный объект также не создаёт файловую загрузку автоматически: это уже другой формат, который сервер должен специально поддерживать.
В руководстве MDN по FormData отдельно указано: при такой отправке нельзя вручную задавать Content-Type. Браузер формирует multipart/form-data вместе с boundary, соответствующим разделителям тела. Проверьте и общую обёртку над fetch: она может незаметно подставлять application/json или неполный multipart-заголовок.
Ниже фрагмент для одного файла и условного обработчика своего сайта. Он предполагает уже настроенные авторизацию и защиту запроса, а также ответ JSON с полями status и fileId. Это пример контракта, который нужно согласовать с сервером.
const input = document.querySelector('#attachment');
const file = input?.files?.[0];
if (!file) throw new Error('Выберите файл');
const data = new FormData();
data.append('attachment', file, file.name);
const response = await fetch('/api/attachments', {
method: 'POST',
body: data
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
if (result.status !== 'stored' || !result.fileId) {
throw new Error('Сохранение вложения не подтверждено');
}
При обычной отправке HTML-формы без fetch требуется enctype="multipart/form-data". Вызов fetch с FormData определяет кодирование тела самостоятельно; добавление enctype не исправит уже заменённое на JSON тело.
Ручная сборка включает только добавленные вами поля. Если действующий обработчик ожидает идентификатор заявки или CSRF-токен, сохраните предусмотренный проектом способ их передачи. Отсутствие этих данных может остановить запрос до загрузки. Не отключайте проверку доступа ради того, чтобы пример с одним файловым полем начал отвечать успехом.
6. Посмотрите фактический payload в Network
Откройте инструменты разработчика до отправки и найдите конкретный запрос во вкладке Network. Сверьте URL, метод, итоговый адрес после перенаправлений и тело ответа. Запрос к странице входа или старому обработчику может завершиться HTTP 200 и вернуть HTML, хотя интерфейс ожидал результат сохранения.
В заголовках ищите Content-Type: multipart/form-data; boundary=..., в Payload — нужное имя поля и файловую часть. Инструменты браузера могут показывать бинарные данные сокращённо: отсутствие развёрнутого содержимого в окне не доказывает отсутствие байтов. Для проверки используйте маленький текстовый файл с известным содержимым.
Content-Type: multipart/form-data; boundary=DemoBoundary143
--DemoBoundary143
Content-Disposition: form-data; name="attachment"; filename="demo.txt"
Content-Type: text/plain
test
--DemoBoundary143--
Это условная иллюстрация структуры, не инструкция собирать запрос вручную. Boundary в заголовке соответствует разделителям тела; у части есть имя поля и имя файла. В рабочем запросе эти детали формирует браузер. Не переносите фиксированный разделитель из примера в настройки клиента.
В отчёт достаточно включить имена полей, размеры, статусы и идентификаторы. Полные дампы запросов могут содержать документы, cookies и токены, поэтому перед передачей подрядчику удалите чувствительные данные.
7. Убедитесь, что запрос разбирает нужный обработчик
Наличие multipart в браузере ещё не означает, что приложение использует парсер этого формата. Обработчик JSON не становится обработчиком файлов из-за изменения заголовка. Проверьте зарегистрированный маршрут, метод и компонент, который превращает части запроса в серверные объекты файла.
В проекте с middleware посмотрите его настройки именно для данного маршрута: разрешённые имена, количество файлов, ограничения и порядок обработки тела. Ошибку парсера нельзя проглатывать и затем возвращать обычный ответ «готово». Если предыдущий компонент уже прочитал поток, выясните, как выбранный стек передаёт его дальше; не подключайте второй парсер наугад.
Сравните минимальный работающий запрос с проблемным: отличается ли только имя поля, количество вложений или тип тела. Проверку выполняйте в согласованном тестовом окружении. Для серверного журнала полезны факт входа в обработчик, число обнаруженных файлов, код отказа и итог сохранения; содержимое документов для этого не требуется.
8. В PHP различайте одиночное поле и массив файлов
Для обычного POST multipart PHP предоставляет данные загрузки в $_FILES. При имени attachment проверяется $_FILES['attachment'], а не $_POST['attachment']. В записи находятся сведения о размере, временном пути и коде загрузки. Структура описана в руководстве PHP по приёму файлов.
Если клиент отправляет files[], PHP создаёт массивы значений внутри $_FILES['files']: например, $_FILES['files']['error'][0]. Это не та же форма данных, что $_FILES['files'][0]['error']. Обработчик одного файла нельзя механически применить к коллекции.
Проверьте наличие ожидаемого ключа и форму его значений до обращения к временному пути. Для одиночного поля неожиданный массив должен стать понятной ошибкой контракта. Если используется другой HTTP-метод или нестандартный способ обработки тела, не переносите предположения о стандартном POST-механизме без проверки документации своей версии и фреймворка.
9. Прочитайте код загрузки и отделите лимит файла от лимита запроса
Запрос «пустой files php» объединяет разные причины. Сначала определите, отсутствует ли массив целиком или внутри есть запись с ошибкой. Значения констант UPLOAD_ERR позволяют выбрать следующий шаг без догадок.
| Код | Значение |
|---|---|
| UPLOAD_ERR_OK, 0 | Загрузка прошла без ошибки; сохранение приложением ещё проверяется |
| UPLOAD_ERR_INI_SIZE, 1 | Превышен upload_max_filesize |
| UPLOAD_ERR_FORM_SIZE, 2 | Превышен MAX_FILE_SIZE, переданный формой |
| UPLOAD_ERR_PARTIAL, 3 | Файл получен частично |
| UPLOAD_ERR_NO_FILE, 4 | Файл не загружен |
| UPLOAD_ERR_NO_TMP_DIR, 6 | Отсутствует временный каталог |
| UPLOAD_ERR_CANT_WRITE, 7 | Не удалось записать файл на диск |
| UPLOAD_ERR_EXTENSION, 8 | Загрузку остановило расширение PHP |
upload_max_filesize ограничивает отдельный файл, post_max_size — весь POST. По документации настроек PHP, превышение второго лимита оставляет пустыми и $_POST, и $_FILES. Условные два файла по 6 МиБ проходят индивидуальный лимит 7 МиБ, но уже дают 12 МиБ данных и не помещаются в общий лимит 10 МиБ, ещё до служебных частей multipart.
Сверяйте значения в обслуживающем сайт окружении, а не только в консольном PHP. Учитывайте file_uploads, настройки чтения POST, число файлов и журналы ошибок. Пустые массивы сами по себе не доказывают превышение размера. Если запрос отвергнут прокси раньше приложения, ищите его ответ отдельно; повышать все ограничения без измерений не нужно.
10. Подтвердите постоянное сохранение и проверку файла
Временный путь не равен постоянному вложению. PHP удаляет неперенесённый временный файл после завершения запроса. Для переноса предусмотрена move_uploaded_file(); её результат необходимо проверить. Она может вернуть false, а существующий файл назначения — перезаписать, поэтому путь хранения формирует приложение по своему безопасному правилу.
На сервере проверяйте допустимость размера, формата и содержимого. Переданный клиентом MIME-тип, расширение и accept в input не являются достаточной проверкой. Рекомендации OWASP по загрузке файлов предусматривают разрешённый набор типов, назначаемые приложением имена, ограниченные права и хранение вне публичного каталога сайта.
Для приватных документов выдавайте доступ через проверку пользователя и прав на конкретное вложение. Привяжите запись файла к нужной заявке, подтвердите возможность чтения сохранённого объекта. Если перенос прошёл, а запись связи не создалась, это частичный результат: предусмотрите обнаружение и уборку такого объекта.
При дополнительной проверке в очереди различайте «получен», «проверяется» и «доступен». Интерфейс должен показывать фактический этап. HTTP 200 с произвольным текстом не заменяет согласованный ответ приложения о результате.
Перед передачей фоновой задаче обеспечьте файлу устойчивое место хранения. Запись в очередь одного временного пути не продлевает жизнь файла после запроса и не делает этот путь доступным другому серверу. В задачу передавайте идентификатор сохранённого объекта и предусмотрите понятный статус при неудачной обработке.
11. Разберите проблему на одном учебном случае
Предположим, форма заявки отправляет текст, а вложение пропадает. Разработчик сначала видит в Network запрос без файловой части. Проверка показывает: перед new FormData(form) код отключал fieldset, защищая форму от повторного нажатия. Данные начали собирать до отключения — и файловая часть появилась.
Но вложение всё ещё не сохраняется. Теперь запрос содержит name="document", а PHP ищет $_FILES['attachment']. Команда согласует одно имя и повторяет испытание. В журнале появляются ожидаемое поле, размер и UPLOAD_ERR_OK. Только после успешного переноса и проверки связи с заявкой интерфейс получает подтверждение.
В этом примере были две независимые ошибки. Исправление первой изменило доказательства, но не завершило задачу. Зафиксируйте состояние до и после каждого изменения: что видел браузер, что получил обработчик и где находится сохранённый документ. Если ответ потерялся после сохранения, сначала проверьте уже созданное вложение по идентификатору операции, прежде чем запускать повторную загрузку и создавать дубликат.
12. Проверьте исправление со стороны пользователя
Приёмку проводите на той же странице и через тот же интерфейс, где возникал сбой. Успешный запрос из отдельного инструмента полезен для локализации, но не проверяет сборку FormData в браузере. Составьте небольшой набор случаев и заранее определите ожидаемый результат каждого.
- Один разрешённый файл сохраняется, привязывается к заявке и открывается пользователю с нужными правами.
- Отсутствующий файл и файл нулевого размера обрабатываются согласно явно заданным правилам.
- Несколько вложений принимаются только поддерживаемым контрактом; лишние поля не создают случайные дубликаты.
- Недопустимый тип и превышение размера дают понятный отказ без ложного уведомления об успехе.
- Ошибка постоянного хранения не скрывается за успешным HTTP-ответом.
- Повторная попытка после неопределённого результата согласуется с уже сохранёнными данными.
Итог исправления — воспроизводимая цепочка от выбранного File до доступного вложения и понятный отказ для неподходящего ввода. Сохраните короткую запись причины, изменённых компонентов и проверенных случаев: она поможет поддержке при следующем изменении формы.
Проверьте и состояние интерфейса после отказа: пользователь должен видеть причину, возможность исправить ввод и результат повторной попытки. Не очищайте форму сразу после запуска запроса. Если файл требуется выбрать заново, скажите об этом явно, чтобы следующая отправка не выглядела исправной при пустом выборе.
Подключите SiteGuard для внешнего наблюдения за HTTP-доступностью сайта. Проверку браузерной формы, передачи вложения и его сохранения включите в отдельные проверки приложения.