Загрузка изображений в Laravel: типичные ошибки и как их избежать
Форма принимает картинку, а через месяц в storage лежат гигабайты мусора, публичные ссылки не работают, а SVG с XSS уже открыли админку. Разбираем, где ломается загрузка изображений в Laravel 11 и как её делать правильно.

Загрузка файла в Laravel выглядит на три строки: $request->file('photo')->store('avatars') — и готово. На демо работает. В продакшене эта строка порождает четыре проблемы разом: файл принимается без валидации типа, оригинал сохраняется как есть (иногда 8 мегабайт с телефона), симлинк на публичное хранилище не создан, а расширение из имени файла верят на слово. Ниже — конкретные приёмы для Laravel 11.x с пакетом обработки изображений, а не пересказ документации.
Ошибка первая: доверять расширению и MIME из запроса
Клиент присылает avatar.jpg, а внутри — PHP-скрипт или SVG с <script>. Метод getClientOriginalExtension() и getClientMimeType() берут данные из заголовков запроса, то есть из того, что прислал клиент. Им нельзя верить.
В валидаторе используйте правило image и mimes, но помните: под капотом mimes проверяет расширение, а mimetypes — реальный MIME через fileinfo. Для картинок надёжнее опираться на второе.
// FormRequest, Laravel 11.x
public function rules(): array
{
return [
'photo' => [
'required',
'file',
// проверяет реальный MIME через getimagesize / fileinfo
'mimetypes:image/jpeg,image/png,image/webp',
'max:5120', // килобайты, то есть 5 МБ
// ограничение размеров, чтобы не грузили 20000x20000
'dimensions:max_width=6000,max_height=6000',
],
];
}
Правило image само по себе разрешает и SVG (image/svg+xml), а SVG — это XML, куда встраивается JavaScript. Если SVG вам не нужен — не добавляйте его в mimetypes. Если нужен, никогда не отдавайте загруженный SVG инлайном с той же оси, что и админка, и прогоняйте его через санитайзер.
Расширение файла — это подпись на конверте, а не содержимое письма. Проверяйте письмо.
Ошибка вторая: сохранять оригинал без обработки
Телефон отдаёт JPEG на 6–10 МБ и стороной 4000+ пикселей. Если сложить это в хранилище как есть, диск заполнится за недели, а страница со списком аватарок будет тянуть десятки мегабайт. Оригинал почти никогда не нужен в исходном виде — его надо ужать и привести к предсказуемому размеру.
Для обработки в экосистеме Laravel используют Intervention Image v3. Обратите внимание: у версии 3 API отличается от версии 2 — метод Image::make() из v2 в v3 заменён на работу через ImageManager. Проверьте, какая версия у вас в composer.json, прежде чем копировать примеры из старых статей.
use Intervention\Image\ImageManager;
use Intervention\Image\Drivers\Gd\Driver;
// Intervention Image 3.x
$manager = new ImageManager(new Driver());
$image = $manager->read($request->file('photo')->getRealPath());
// вписываем в 800x800 без искажения пропорций,
// не увеличивая, если картинка меньше
$image->scaleDown(width: 800, height: 800);
// кодируем в webp с качеством 80 и сохраняем
$encoded = $image->toWebp(quality: 80);
$path = 'avatars/' . uniqid('img_', true) . '.webp';
Storage::disk('public')->put($path, (string) $encoded);
Метод scaleDown не увеличивает изображение — маленькая картинка останется маленькой, а не растянется в кашу. Если нужна именно обрезка под квадрат — используйте cover вместо scaleDown. Точные сигнатуры методов сверяйте с документацией v3, они менялись.
GD или Imagick
Intervention работает через один из двух драйверов. GD идёт почти везде и быстрее на простых операциях; Imagick лучше держит цветовые профили и качество при ресайзе, но требует установленного расширения. Для аватарок и превью разницы вы не заметите — берите GD. Для фото, где важна цветопередача, ставьте Imagick.
Ошибка третья: неправильный диск и отсутствие симлинка
Классика: файл сохранён через Storage::disk('public')->put(...), в базе лежит путь, а по ссылке /storage/avatars/img.webp — 404. Причина в том, что диск public физически пишет в storage/app/public, а веб-сервер отдаёт public/. Между ними нужен симлинк.
- Выполните один раз на сервере:
php artisan storage:link. Команда создаёт симлинкpublic/storage→storage/app/public. - Проверьте: в браузере откройте
ваш-домен/storage/имя-файла— должна отдаться картинка, а не 404. - Для генерации ссылки в коде используйте
Storage::disk('public')->url($path), а не склеивайте строку руками.
Симлинк не переживает деплой, если вы разворачиваете проект в новую папку и не переносите его. Добавьте storage:link в скрипт деплоя. На shared-хостинге симлинки иногда запрещены — тогда придётся настраивать отдачу файлов через контроллер или менять корень веб-сервера.
Ошибка четвёртая: имя файла от пользователя
Никогда не сохраняйте файл под именем, которое прислал клиент. Имя вроде ../../.env или index.php — прямой путь к перезаписи чужих файлов и обходу каталога. Генерируйте имя сами: uniqid(), Str::uuid() или хеш содержимого. Метод store() в Laravel делает это за вас, если вы не вызываете storeAs() с пользовательским именем.
Ещё одна ловушка — коллизии. Если два пользователя грузят файл в одну секунду и вы берёте имя из time(), один перезапишет другой. Str::uuid() или uniqid('', true) с флагом энтропии решают это.
Сравнение подходов к хранению
| Подход | Плюс | Минус | Когда |
|---|---|---|---|
Диск public + симлинк | просто, бесплатно | файлы на том же сервере, не масштабируется | небольшой проект, один сервер |
| Приватный диск + контроллер отдачи | контроль доступа к каждому файлу | нагрузка на PHP при отдаче | файлы с ограничением доступа |
| S3-совместимое хранилище | масштаб, CDN, отдельно от приложения | стоимость, настройка, задержка загрузки | много файлов, несколько серверов |
Драйвер s3 требует пакета league/flysystem-aws-s3-v3. Прелесть в том, что код с Storage::disk('s3') не отличается от кода с public — меняется только конфиг диска в config/filesystems.php. Абстракция Flysystem под капотом ровно для этого.
Ошибка пятая: тяжёлая обработка в HTTP-запросе
Если вы ресайзите большое фото прямо в контроллере, пользователь ждёт весь запрос, а при пакетной загрузке это укладывает воркеров. Ресайз, генерацию нескольких размеров и загрузку в S3 выносите в очередь.
// в контроллере — только приём и быстрое сохранение оригинала
$path = $request->file('photo')->store('uploads/tmp', 'local');
// тяжёлое — в фоновую задачу
ProcessUploadedImage::dispatch($path);
Внутри задачи читаете временный файл, генерируете нужные размеры, кладёте в постоянное хранилище и удаляете временный. Для очереди нужен настроенный драйвер (QUEUE_CONNECTION в .env) и запущенный php artisan queue:work или Horizon.
Если не получилось
- Ошибка
Class "Intervention\Image\ImageManager" not found— пакет не установлен или установлена v2 с другим неймспейсом. Проверьтеcomposer show intervention/image. - Файл грузится, но 413 Request Entity Too Large — упирается в лимит веб-сервера, а не PHP. Смотрите
client_max_body_sizeв Nginx иupload_max_filesize,post_max_sizeвphp.ini. - Картинка сохранилась, но по ссылке 404 — не выполнен
storage:linkили неверный диск при генерации URL. - Валидация пропускает файл не того типа — вы используете
mimes(проверка по расширению) вместоmimetypes(проверка реального содержимого). - Ошибка памяти
Allowed memory size exhausted— огромное изображение разворачивается в RAM при декодировании. Ограничьтеdimensionsв валидаторе до обработки.
Что настроить дальше
После базовой загрузки смотрите в сторону трёх вещей: генерация нескольких размеров (thumbnail, medium, original) в одной фоновой задаче; очистка осиротевших файлов — тех, что загрузились, но запись в БД не создалась (отдельная тема, здесь не разворачиваем); отдача через CDN, если трафик картинок заметен в метриках. Померьте средний размер файла в хранилище и время ответа эндпоинта загрузки — эти два числа скажут, пора ли выносить обработку в очередь.
Редактор изображений: Убрать лишнее, заменить фон, добавить объект, расширить кадр — на вашем изображении.
Спросить за 20 ₽Источники: Intervention Image v3 — Documentation, Laravel 11.x — File Storage
Частые вопросы
У меня Intervention Image версии 2, примеры не работают. Что делать?
Как ограничить максимальный размер загружаемого файла?
Стоит ли хранить оригинал изображения?
Безопасно ли принимать SVG?
Почему после деплоя перестали открываться картинки?
Что произойдёт, если два пользователя загрузят файл одновременно?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.