Загрузка файла в PHP — это прием файла, отправленного браузером через форму с кодировкой multipart/form-data, и перенос его из временного каталога в постоянное хранилище. Браузер кладет данные в суперглобальный массив $_FILES, а скрипт проверяет их и вызывает move_uploaded_file.
Содержание
Ниже — минимальный рабочий пример формы и обработчика, разбор массива $_FILES, таблица кодов ошибок и главное: как не превратить загрузчик в дыру, через которую заливают исполняемый .php-файл. Примеры проверены на PHP 8.5 (актуальная стабильная ветка на сентябрь 2026; тот же код работает и на PHP 8.1-8.4).
HTML-форма для загрузки
Файл попадет на сервер только при двух условиях: у формы метод post и атрибут enctype="multipart/form-data". Без enctype браузер отправит лишь имя файла как обычное текстовое поле, а $_FILES останется пустым.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Загрузка файла</title>
</head>
<body>
<form action="upload.php" method="post" enctype="multipart/form-data">
<input type="hidden" name="MAX_FILE_SIZE" value="2097152">
<label for="userfile">Выберите файл:</label>
<input type="file" id="userfile" name="userfile">
<button type="submit">Загрузить</button>
</form>
</body>
</html>
Имя поля (name="userfile") становится ключом в массиве: данные окажутся в $_FILES['userfile']. Скрытое поле MAX_FILE_SIZE не «подсказка браузеру»: его значение обрабатывает сам PHP — если файл превысит указанный размер, загрузка прервется с кодом UPLOAD_ERR_FORM_SIZE (2). Но клиент может это поле изменить или вовсе удалить, поэтому серверным барьером оно не является. Настоящий лимит задают директивы upload_max_filesize, post_max_size и собственная проверка размера на сервере, к которой мы вернемся ниже.
Что лежит в массиве $_FILES
После отправки формы $_FILES['userfile'] — это ассоциативный массив. Посмотреть его структуру можно так:
<?php
print_r($_FILES);
Для одного загруженного JPEG вывод будет примерно таким:
Array
(
[userfile] => Array
(
[name] => photo.jpg
[full_path] => photo.jpg
[type] => image/jpeg
[tmp_name] => /tmp/phpAb12Cd
[error] => 0
[size] => 6887
)
)
Ключевой момент: name и type присылает браузер, им доверять нельзя. type — это заявленный клиентом MIME, его легко подделать. Значение имеет только tmp_name (путь во временном каталоге, куда PHP уже сохранил файл), size (размер в байтах, измеренный сервером) и error (код результата).
| Поле | Кто задает | Можно доверять | Назначение |
|---|---|---|---|
name |
браузер | нет | исходное имя файла у клиента |
type |
браузер | нет | заявленный MIME-тип |
tmp_name |
PHP | да | путь к временной копии на сервере |
size |
PHP | да | размер в байтах |
error |
PHP | да | код ошибки загрузки |
Коды ошибок загрузки
Поле error — всегда первое, что нужно проверить. Каждому коду соответствует именованная константа PHP; сравнивать лучше с константой, а не с числом.
| Константа | Код | Что означает |
|---|---|---|
UPLOAD_ERR_OK |
0 | файл загружен без ошибок |
UPLOAD_ERR_INI_SIZE |
1 | превышен upload_max_filesize из php.ini |
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 |
Кода 5 в списке нет: он не используется. Значения 1 и 2 отличаются источником лимита: код 1 — серверный php.ini, код 2 — поле MAX_FILE_SIZE в форме.
Небезопасный прием и его последствие
Самая частая ошибка новичка — сохранить файл под тем именем, что прислал клиент, прямо в каталог сайта:
<?php
// НЕБЕЗОПАСНО: имя берётся от клиента, каталог доступен по HTTP
$name = $_FILES['userfile']['name'];
move_uploaded_file($_FILES['userfile']['tmp_name'], 'uploads/' . $name);
echo 'Загружено: ' . $name;
Что произойдет: злоумышленник отправит через форму файл shell.php с PHP-кодом внутри. Скрипт послушно положит его в uploads/shell.php — в каталог, который отдается веб-сервером. Дальше атакующий открывает https://site.ru/uploads/shell.php, и сервер исполняет чужой код: это полный захват сайта (RCE). Проверка типа по $_FILES['type'] тут не спасает — этот MIME присылает клиент, и подделывается он так же легко, как и имя.
Базовый защищенный обработчик
Базовый защищенный обработчик строится на нескольких независимых проверках. Каждая закрывает свой класс атак. Это именно базовый шаблон против самого частого риска — заливки исполняемого файла; полный production-контур защиты шире, и что в него входит, я перечислю ниже.
<?php
// 1. Проверяем код ошибки
if (!isset($_FILES['userfile']) || $_FILES['userfile']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
exit('Файл не загружен или пришёл с ошибкой');
}
$file = $_FILES['userfile'];
// 2. Файл действительно пришёл через HTTP POST, а не подсунут по пути
if (!is_uploaded_file($file['tmp_name'])) {
http_response_code(400);
exit('Некорректная загрузка');
}
// 3. Серверный лимит размера (не доверяем форме)
$maxBytes = 2 * 1024 * 1024; // 2 МБ
if ($file['size'] > $maxBytes) {
http_response_code(400);
exit('Файл больше 2 МБ');
}
// 4. Реальный MIME по содержимому файла, а не по имени
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
// 5. Белый список: разрешённый тип -> расширение, которое присвоим сами
$allowed = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'application/pdf' => 'pdf',
];
if (!isset($allowed[$mime])) {
http_response_code(400);
exit('Недопустимый тип файла');
}
// 6. Генерируем новое имя, расширение берём из белого списка
$newName = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
// 7. Каталог хранения - вне корня сайта
$target = '/var/www/storage/uploads/' . $newName;
if (!move_uploaded_file($file['tmp_name'], $target)) {
http_response_code(500);
exit('Не удалось сохранить файл');
}
echo 'Файл сохранён как ' . $newName;
Разберем принципы:
is_uploaded_fileподтверждает, что путь ведет к файлу, полученному именно через загрузку.move_uploaded_fileделает ту же проверку внутри, поэтому связка исключает подменуtmp_nameна произвольный путь.- MIME определяем расширением
fileinfoпо содержимому (finfo), а расширение и имя генерируем сами черезrandom_bytes. Клиентскиеnameиtypeне участвуют в имени файла — заливкаshell.phpтеряет смысл, потому что файл все равно сохранится как случайный.jpg. - Хранилище вынесено за пределы webroot (
/var/www/storage/, а не/var/www/html/). Файл физически недоступен по прямой ссылке; отдаем его при необходимости отдельным скриптом, который сам решает, кому и что показать.
Основной способ — именно хранение вне webroot: тогда вопрос «исполнит сервер файл или нет» просто не возникает. Если по инфраструктуре хранить вне корня сайта нельзя, запретить исполнение скриптов в каталоге загрузок нужно средствами конкретного веб-сервера, а не универсальной строкой. Частый совет php_flag engine off в .htaccess переносим плохо: он относится к конфигурациям с mod_php, при PHP-FPM может не примениться или вызвать ошибку конфигурации, а сам .htaccess может быть отключен директивой AllowOverride None. Правильный порядок такой: отключить обработчики скриптов в проверенной конфигурации своего сервера (Apache или Nginx), запретить выполнение в каталоге и обязательно протестировать — положить туда контрольный .php-файл и убедиться запросом, что сервер отдает его как текст, а не исполняет.
Важно понимать границу: проверка MIME через finfo отсекает грубые подмены, но для некоторых форматов ее обходят (например, «полиглот»-файл, валидный и как картинка, и как скрипт). Поэтому для изображений надежнее пересохранять их через библиотеку обработки (GD/Imagick) — при перекодировании чужой код внутри файла не выживает. Белый список расширений всегда безопаснее черного: список запрещенных легко обойти регистром или менее известным исполняемым расширением.
Что еще нужно в production
Код выше закрывает заливку веб-шелла, но загрузка файлов — это не только риск исполняемого скрипта. Прежде чем считать загрузчик готовым к бою, добавьте к нему меры, которых в учебном примере нет:
- Аутентификация и авторизация — кто вообще имеет право загружать файл, и проверка прав на конкретное действие.
- Защита от CSRF — токен в форме, чтобы загрузку нельзя было инициировать с чужого сайта.
- Квоты и лимиты — максимальное число файлов, суммарный объем на пользователя, защита от переполнения диска.
- Сканирование содержимого — антивирус или sandbox для потенциально опасных типов; помните про ZIP/XML-бомбы, вредоносные документы и уязвимости парсеров.
- Права доступа и журналирование — минимальные права на каталог хранения, запись факта загрузки в лог.
- Безопасная выдача файла — отдавать через скрипт с проверкой прав и корректными заголовками (
Content-Type,Content-Disposition), а не прямой ссылкой. - Политика удаления — когда и как файлы удаляются, чтобы хранилище не росло бесконтрольно.
Ориентир по полному списку мер — OWASP File Upload Cheat Sheet.
Лимиты размера
Максимальный размер задается в php.ini и не может быть увеличен из самого скрипта:
upload_max_filesize— предельный размер одного файла.post_max_size— предельный размер всего POST-запроса; должен быть строго большеupload_max_filesize, а не равен ему: POST несет не только файл, но и служебные части multipart и другие поля формы, поэтому файл у самой границыupload_max_filesizeпри равных лимитах может не пройти. Практический ориентир:upload_max_filesize = 2M,post_max_size = 3Mили выше с запасом на остальные поля.
Поле MAX_FILE_SIZE в форме, как я разобрал выше, обрабатывает PHP, но клиент может его подделать, поэтому итоговым барьером всегда должна быть серверная проверка $file['size'], как в примере.
Выводы
- Форма загрузки обязана иметь
method="post"иenctype="multipart/form-data", иначе$_FILESбудет пустым. - Из
$_FILESдоверять можно толькоtmp_name,sizeиerror; поляnameиtypeприсылает клиент, их подделывают. - Начинайте обработку с проверки
error === UPLOAD_ERR_OKиis_uploaded_file, затем переносите файл черезmove_uploaded_file. - Сохранение файла под клиентским именем в каталог сайта — прямой путь к заливке веб-шелла и захвату сервера.
- Базовая защита: серверный лимит размера, определение реального MIME по содержимому, белый список типов, новое сгенерированное имя и хранение вне webroot (запрет исполнения внутри каталога — только средствами конкретного веб-сервера и с проверкой контрольным файлом).
- Это базовый шаблон против веб-шелла; для production добавьте аутентификацию и CSRF-защиту, квоты, сканирование содержимого, безопасную выдачу файла и журналирование.
Где применяется / связь с практикой
Загрузка файлов — базовый кирпич почти любого веб-приложения на PHP: аватары в профиле, вложения в тикетах, документы в личном кабинете, импорт данных из CSV. Ошибка именно здесь чаще всего оборачивается взломом, поэтому обработку $_FILES и защиту загрузчика разбирают на серверной разработке как отдельную тему с практикой на реальных фреймворках.
Освойте тему на практике
Системно освоить серверный PHP, работу с файлами и безопасность веб-приложений можно на курсе PHP-разработчик. Посмотреть формат занятий и темы вживую помогут открытые уроки Otus — бесплатные вебинары с практикой и разбором задач.
FAQ
Можно ли загрузить несколько файлов одной формой?
Да. Задайте полю имя-массив (name="userfile[]") и атрибут multiple; тогда $_FILES['userfile'] придет как массивы по каждому ключу (name, tmp_name, error и т.д.), которые перебирают в цикле, применяя те же проверки к каждому файлу.
Почему файл не загружается, хотя ошибок в коде нет?
Чаще всего дело в серверных лимитах: файл больше upload_max_filesize (код ошибки 1) или весь запрос больше post_max_size. Тонкость: если превышен лимит всего POST-запроса, PHP оставляет и $_POST, и $_FILES пустыми — кода UPLOAD_ERR_* для чтения уже не будет, а обработчик выдаст общее «файл не загружен». Чтобы отличить этот случай, до обращения к $_FILES сравните заголовок CONTENT_LENGTH с разобранным значением post_max_size и обработайте превышение отдельным сообщением. В остальных случаях проверяйте $_FILES['userfile']['error'] и настройки php.ini.
Достаточно ли проверить расширение файла в имени?
Нет. Имя и его расширение задает клиент, их подделывают. Определяйте тип по содержимому через finfo и сверяйте с белым списком, а имя для сохранения генерируйте сами.



