Загрузка файлов на сервер через PHP: разбор на живом примере
Форма с input type=file, обработчик на PHP и десяток способов положить сервер или дырку в безопасность. Разбираем, что происходит между кнопкой «Отправить» и файлом на диске.

Пользователь выбирает картинку, жмёт «Загрузить», и через секунду видит «Файл успешно сохранён». За этой секундой — временный файл в системной директории, проверка MIME-типа, лимиты в трёх разных конфигах и перемещение через move_uploaded_file. Стоит ошибиться в одном месте — и вы либо режете пользователю файл на 20 МБ без объяснений, либо пускаете на сервер PHP-скрипт под видом аватарки. Разбираем на рабочем обработчике, что происходит на каждом шаге и где чаще всего ломается. Примеры проверены на PHP 8.2 и 8.3.
Что происходит при загрузке файла
Когда браузер отправляет форму с enctype="multipart/form-data", PHP принимает тело запроса, сохраняет каждый файл во временную директорию и заполняет суперглобальный массив $_FILES. К моменту запуска вашего скрипта файл уже на диске сервера — во временном месте с автоматически сгенерированным именем.
Массив $_FILES['field'] содержит пять ключей:
name— оригинальное имя файла с клиента. Ему нельзя доверять: пользователь может прислать что угодно.type— MIME-тип, который заявил браузер. Тоже подделывается тривиально, проверять по нему нельзя.tmp_name— путь к временному файлу на сервере. С ним и работаем.error— код ошибки загрузки, целое число. Проверять в первую очередь.size— размер в байтах, посчитанный сервером.
Временный файл живёт до конца обработки запроса. Если вы его не переместите через move_uploaded_file, PHP удалит его автоматически.
Минимальный рабочий обработчик
Форма для загрузки одного изображения:
<form action="upload.php" method="post" enctype="multipart/form-data">
<input type="hidden" name="MAX_FILE_SIZE" value="5242880">
<input type="file" name="photo" accept="image/*">
<button type="submit">Загрузить</button>
</form>
Скрытое поле MAX_FILE_SIZE — подсказка браузеру, а не защита: клиент может его проигнорировать. Реальный лимит задаётся на сервере, к этому вернёмся.
Обработчик upload.php с проверками по порядку:
<?php
declare(strict_types=1);
// Каталог назначения вне корня сайта или закрытый от прямого доступа
$targetDir = __DIR__ . '/uploads/';
$maxSize = 5 * 1024 * 1024; // 5 МБ
// Разрешённые типы: соответствие реального MIME и расширения
$allowed = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
];
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit('Метод не поддерживается');
}
// 1. Файл вообще пришёл?
if (!isset($_FILES['photo'])) {
exit('Поле photo отсутствует');
}
$file = $_FILES['photo'];
// 2. Проверяем код ошибки загрузки
if ($file['error'] !== UPLOAD_ERR_OK) {
// UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_PARTIAL и другие
exit('Ошибка загрузки, код: ' . $file['error']);
}
// 3. Файл действительно загружен через HTTP, а не подсунут путём
if (!is_uploaded_file($file['tmp_name'])) {
exit('Недопустимый источник файла');
}
// 4. Размер — своя проверка, не полагаемся на клиента
if ($file['size'] > $maxSize) {
exit('Файл больше 5 МБ');
}
// 5. Определяем реальный MIME по содержимому, а не по $file['type']
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
if (!isset($allowed[$mime])) {
exit('Недопустимый тип файла: ' . $mime);
}
// 6. Генерируем безопасное имя сами, оригинал не используем
$ext = $allowed[$mime];
$newName = bin2hex(random_bytes(16)) . '.' . $ext;
$targetPath = $targetDir . $newName;
// 7. Перемещаем из временного каталога
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
exit('Не удалось сохранить файл');
}
echo 'Сохранено как ' . htmlspecialchars($newName);
Каждая из семи проверок закрывает конкретный класс проблем. Убрать любую — и появляется дыра, которую находят автоматизированные сканеры за минуты.
Почему нельзя доверять $_FILES['type']
Значение в type берётся из заголовка, который прислал браузер. Атакующий не пользуется браузером — он шлёт запрос напрямую и указывает image/jpeg для файла shell.php. Единственный надёжный способ — читать сигнатуру содержимого через finfo (расширение fileinfo, включено в PHP по умолчанию с версии 5.3).
Правило, которое экономит инциденты: имя файла придумывает сервер, а не пользователь. Оригинальное имя — это ненадёжный ввод, а не идентификатор.
Три лимита размера, которые надо согласовать
Самая частая жалоба новичка: «загружаю файл на 8 МБ, а PHP говорит, что всё в порядке, но файла нет» или «форма просто зависает». Причина в том, что размер загрузки ограничен в нескольких местах, и меньшее из значений побеждает. Проверяйте не только PHP, но и веб-сервер.
| Где | Директива / настройка | Что ограничивает |
|---|---|---|
| php.ini | upload_max_filesize | Размер одного загружаемого файла |
| php.ini | post_max_size | Размер всего POST-запроса, включая все файлы и поля |
| php.ini | max_file_uploads | Число файлов за один запрос |
| php.ini | memory_limit | Косвенно: обработка больших файлов в памяти |
| Nginx | client_max_body_size | Размер тела запроса на уровне веб-сервера |
| Apache | LimitRequestBody | То же для Apache |
Логика простая: post_max_size должен быть больше upload_max_filesize, потому что POST несёт ещё и служебные данные. А лимит веб-сервера (client_max_body_size в Nginx) должен быть не меньше post_max_size, иначе запрос отрежется до того, как дойдёт до PHP — и вы получите ошибку 413 от Nginx, а не сообщение от своего скрипта. Точные имена директив сверяйте с документацией вашей версии Nginx или Apache, они стабильны, но версии конфигов различаются.
Проверить текущие значения PHP:
php -i | grep -E 'upload_max_filesize|post_max_size|max_file_uploads'
Куда класть загруженные файлы
Если каталог uploads/ лежит внутри корня сайта и доступен по прямой ссылке, любой загруженный файл выполнится веб-сервером как скрипт — при условии, что через MIME-проверку прошёл, например, полиглот (файл, который одновременно валидная картинка и валидный PHP). Два надёжных подхода:
- Хранить вне DocumentRoot и отдавать через PHP-скрипт с проверкой прав. Прямого URL к файлу не существует.
- Запретить исполнение в каталоге. Для Nginx — не назначать этому location обработку через PHP-FPM. Для Apache положить в каталог
.htaccessс директивой, отключающей выполнение PHP.
Отдача файла из закрытого каталога через скрипт:
<?php
$file = $storageDir . '/' . basename($_GET['id']); // basename режет ../
if (!is_file($file)) {
http_response_code(404);
exit;
}
header('Content-Type: ' . (new finfo(FILEINFO_MIME_TYPE))->file($file));
header('Content-Length: ' . filesize($file));
readfile($file);
Вызов basename здесь — защита от path traversal: без неё параметр вида ../../etc/passwd отдал бы системный файл.
Если не получилось
Частые ошибки и что с ними делать:
- «File is too big» или файл просто не приходит, но
errorравен 1 (UPLOAD_ERR_INI_SIZE). Файл превысилupload_max_filesize. Поднимите значение в php.ini и перезапустите PHP-FPM. - 413 Request Entity Too Large от Nginx. Запрос больше
client_max_body_size. Увеличьте директиву в конфиге Nginx и перезагрузите его — до PHP запрос вообще не дошёл. $_FILESпустой, хотя файл выбран. Проверьте, что у формы стоитenctype="multipart/form-data"и метод POST. Без enctype браузер отправит только имя файла.errorравен 6 (UPLOAD_ERR_NO_TMP_DIR). Не задана или недоступна временная директория. Проверьтеsys_get_temp_dir()и права на неё.move_uploaded_fileвозвращает false. Чаще всего у процесса PHP (пользовательwww-data) нет прав на запись в целевой каталог. Проверьте владельца и права каталогаuploads/.
Что настроить дальше
Базовый обработчик работает, но для продакшена добавьте ещё три вещи. Первое — обработку нескольких файлов через name="photo[]": массив $_FILES в этом случае устроен иначе, каждый ключ становится массивом, обходить придётся по индексу. Второе — перекодирование изображений через GD или Imagick: пересохранение картинки убивает вложенный в неё вредоносный код надёжнее любой проверки сигнатуры. Третье — ограничение по типу через accept в форме плюс серверная валидация: клиентская подсказка улучшает UX, но защищает только сервер.
Валидация загружаемых архивов, документов и работа с потоковой загрузкой больших файлов через chunked upload — отдельные темы, здесь мы разобрали классический случай с изображениями.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: PHP Manual — Handling file uploads, PHP Manual — move_uploaded_file
Частые вопросы
У меня PHP 7.4, а не 8.x — примеры подойдут?
Файл на 10 МБ не загружается, хотя upload_max_filesize я уже поднял. В чём дело?
Достаточно ли проверять расширение файла по имени?
Насколько опасно хранить загрузки в каталоге внутри сайта?
Как обработать загрузку сразу нескольких файлов?
Проверка через finfo гарантирует, что в картинке нет вредоносного кода?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.