Загрузка изображений в Laravel: чек-лист перед продом
Форма загрузки аватарок работает на локали, а на бою кладёт сервер OOM-ом и позволяет залить PHP-шелл под видом картинки. Разбираем, что проверить до релиза.

Типичная история: форма загрузки картинок принимает файл, кладёт его в storage/app/public, отдаёт ссылку — и всё работает на локальной машине с одним пользователем. На проде выясняется, что валидация по расширению пропускает shell.php.jpg, GD падает с Allowed memory size exhausted на фотографии с телефона в 24 мегапикселя, а один злоумышленник забивает диск за десять минут. Ниже — чек-лист для Laravel 11.x и 12.x: что закрыть перед тем, как выкатывать загрузку изображений наружу.
Статья про безопасность и устойчивость самой загрузки. Хранение в S3, CDN и генерация превью на лету — смежные темы, здесь их не разворачиваем; фокус на приёме файла, валидации и обработке.
Что должно быть готово до чтения
- Laravel 11.x или 12.x, PHP 8.2+.
- Установлен драйвер обработки изображений — GD (обычно есть) или Imagick. Проверить:
php -m | grep -E 'gd|imagick'. - Если используете пакет
intervention/image— версия 3.x (API сильно отличается от 2.x, примеры ниже для тройки). - Настроен и примонтирован диск в
config/filesystems.php, для публичных файлов выполненphp artisan storage:link.
Валидация: расширение — это не тип
Первая ошибка — доверять имени файла. Правило mimes:jpg,png в Laravel проверяет MIME по содержимому через угадывание, но начинающие часто пишут проверку расширения руками и получают дыру. Правильный набор правил в form request:
// app/Http/Requests/UploadAvatarRequest.php
public function rules(): array
{
return [
'avatar' => [
'required',
'file',
// проверяет реальный MIME-тип содержимого
'mimetypes:image/jpeg,image/png,image/webp',
// ограничение размера в килобайтах: здесь 5 МБ
'max:5120',
// валидатор изображения + границы размеров в пикселях
'dimensions:min_width=100,min_height=100,max_width=6000,max_height=6000',
],
];
}
Разница между mimes и mimetypes важна: mimes:png сверяет расширение с ожидаемым MIME, а mimetypes:image/png смотрит на фактический тип, который Laravel определяет через finfo. Для защиты от подмены нужен второй вариант. Правило dimensions отсекает как микрокартинки-заглушки, так и гигантские изображения, которые положат память ещё до вашей обработки.
Файл с расширением .jpg, внутри которого PHP-код, — не гипотетика, а классика. Единственная надёжная защита: хранить загруженное вне webroot и никогда не отдавать через интерпретатор.
Имя файла генерируем сами
Никогда не сохраняйте файл под именем, которое пришло от пользователя. В нём может быть ../, нулевой байт, двойное расширение или просто кириллица, ломающая пути. Laravel даёт готовый способ:
// имя генерируется как случайный хеш, расширение берётся по MIME
$path = $request->file('avatar')->store('avatars', 'public');
// либо с явным именем без пользовательского ввода
$path = $request->file('avatar')->storeAs(
'avatars',
Str::uuid() . '.' . $request->file('avatar')->extension(),
'public'
);
Метод extension() у загруженного файла возвращает расширение по угаданному MIME, а не по имени из формы — это то, что нужно. Метод clientExtension(), наоборот, берёт расширение из имени клиента, ему доверять нельзя.
Обработка: где кончается память
GD загружает изображение в память в несжатом виде. Фотография 6000×4000 в JPEG весит на диске 3 МБ, но в памяти при обработке — это примерно 6000 × 4000 × 4 байта, около 96 МБ на одну картинку. При memory_limit=128M в PHP-FPM пара параллельных запросов кладёт воркер. Отсюда два вывода: ограничивайте входные размеры валидацией (см. dimensions выше) и уменьшайте картинку сразу, а не после.
Пример с intervention/image 3.x — привести к превью и сохранить в WebP:
use Intervention\Image\ImageManager;
use Intervention\Image\Drivers\Gd\Driver;
$manager = new ImageManager(new Driver());
$image = $manager->read($request->file('avatar')->getRealPath());
// вписать в 512x512 с сохранением пропорций, без увеличения мелких
$image->scaleDown(width: 512, height: 512);
// перекодировать в WebP с качеством 80 и записать на диск
$encoded = $image->toWebp(quality: 80);
Storage::disk('public')->put('avatars/' . Str::uuid() . '.webp', (string) $encoded);
В версии 3.x метод называется scaleDown (не увеличивает изображение сверх исходного), тогда как scale может и увеличить. В intervention/image 2.x этих имён не было — там был resize с колбэком. Если у вас 2.x, синтаксис другой, сверьтесь с документацией пакета.
Тяжёлую обработку — в очередь
Ресайз, множественные превью, перекодирование — это секунды процессорного времени. Держать HTTP-воркер занятым на всё это время — способ исчерпать пул FPM под нагрузкой. Сохраните оригинал, поставьте job в очередь, отдайте пользователю ответ сразу:
// в контроллере
$path = $request->file('avatar')->store('avatars/originals', 'public');
ProcessAvatar::dispatch($path);
Так пик загрузок не превращается в пик времени ответа. Для этого нужен настроенный QUEUE_CONNECTION (не sync) и запущенный php artisan queue:work или Horizon.
Хранение и раздача
Загруженные файлы не должны лежать там, где их может исполнить веб-сервер. Диск public в Laravel указывает на storage/app/public, а storage:link делает симлинк в public/storage. Это удобно, но убедитесь, что Nginx отдаёт эту директорию как статику, а не через PHP. Минимальная гигиена конфигурации Nginx для директории загрузок:
# отдавать файлы напрямую, не передавать в PHP-FPM
location ^~ /storage/ {
location ~ \.php$ { return 403; }
}
Строка с return 403 закрывает исполнение .php внутри загрузок, даже если туда что-то просочилось. Точный синтаксис зависит от вашего базового конфига — сверьтесь с документацией Nginx по вложенным location.
Сравнение подходов к обработке
| Подход | Плюс | Минус | Когда |
|---|---|---|---|
| Синхронно в контроллере | Просто, результат сразу | Держит FPM-воркер, риск OOM под нагрузкой | MVP, редкие загрузки |
| В очереди (job) | Ответ мгновенный, пики сглаживаются | Нужна инфраструктура очередей, превью не сразу | Публичная загрузка, заметный трафик |
| Превью на лету при запросе | Не хранишь лишние размеры | Нагрузка на каждый показ, нужен кеш | Много размеров, редкий просмотр |
Если не получилось
- Allowed memory size of N bytes exhausted — картинка больше, чем помещается в
memory_limit. Не поднимайте лимит бесконечно: ограничьте входныеdimensionsи уменьшайте изображение до чтения в память там, где это возможно. - The avatar failed to upload — файл превысил
upload_max_filesizeилиpost_max_sizeв PHP. Правилоmaxв Laravel сработает только для файлов, которые уже дошли; лимиты PHP проверяются раньше. - Симлинк не работает, 404 на /storage/... — не выполнен
php artisan storage:linkлибо симлинк указывает на неверный путь после переезда. Удалитеpublic/storageи создайте заново. - Class "Intervention\Image\ImageManager" not found — пакет не установлен или установлена версия 2.x с другими неймспейсами. Проверьте
composer show intervention/image. - Загрузился .jpg, а это переименованный текст — правило
mimetypesне стоит либо стоитmimesс доверием к расширению. Проверьте, что валидируете именноmimetypes.
Что настроить перед релизом
- Валидация через
mimetypesиdimensions, а не по расширению. - Имя файла генерируется на сервере, расширение — по фактическому MIME.
- Тяжёлая обработка вынесена в очередь,
QUEUE_CONNECTIONнеsync. - Nginx отдаёт директорию загрузок как статику и не исполняет там PHP.
- Ограничения
upload_max_filesize,post_max_sizeиmemory_limitсогласованы между собой и с правиломmax. - Настроена очистка оригиналов и осиротевших файлов — иначе диск заполнится.
Следующий шаг после этого — метрики: смотрите за временем ответа эндпоинта загрузки и за глубиной очереди обработки. Если очередь растёт быстрее, чем разбирается, добавляйте воркеры или выносите ресайз на отдельную машину.
Редактор изображений: Убрать лишнее, заменить фон, добавить объект, расширить кадр — на вашем изображении.
Спросить за 20 ₽Источники: Laravel Filesystem / File Storage documentation, Intervention Image v3 documentation
Частые вопросы
У меня intervention/image 2.x, примеры не работают
Достаточно ли правила mimes для защиты от загрузки PHP-шелла?
Что делать, если валидация max проходит, а файл всё равно не загружается?
Нужна ли очередь, если загрузок мало?
GD или Imagick — что выбрать?
Как чистить оригиналы и осиротевшие файлы?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.