Валидация файлов и изображений в Laravel
Загрузка файлов через форму – одна из задач, где валидация критически важна. Подменённое расширение или чрезмерный размер создают реальную угрозу для приложения. Laravel предоставляет набор правил для проверки загруженных файлов: от базового file до fluent-билдера File, объединяющего несколько проверок в цепочку вызовов.
Правило file
Минимальная проверка – поле должно содержать успешно загруженный файл:
$request->validate([
'document' => ['required', 'file'],
]);
Правило file гарантирует, что значение поля является экземпляром UploadedFile с успешным статусом загрузки (PHP error code = UPLOAD_ERR_OK). Файл, который не дошёл до сервера из-за превышения лимита или обрыва соединения, не пройдёт эту проверку. Если поле необязательно, используйте nullable:
$request->validate([
'attachment' => ['nullable', 'file', 'max:5120'],
]);
Без правила file остальные файловые проверки (mimes, max для размера) могут работать непредсказуемо, получив на вход не тот тип данных. Если злоумышленник отправит строку вместо файла, mimes выбросит исключение вместо корректной ошибки валидации. Правило file – первая линия защиты. Fluent-билдер File::types() и File::image() добавляют это правило автоматически, явно указывать file рядом с ними не нужно.
Проверка типа файла: mimes
Правило mimes проверяет MIME-тип содержимого файла, а не расширение в имени. Указываются расширения, но Laravel по ним определяет допустимые MIME-типы и сверяет с реальным содержимым:
$request->validate([
'photo' => ['required', 'file', 'mimes:jpg,jpeg,png,webp'],
]);
Под капотом Laravel делегирует определение MIME-типа Symfony MimeTypeGuesser, который опирается на finfo_file() и другие стратегии для анализа magic bytes в начале файла. Переименованный script.php в script.jpg не пройдёт проверку – mimes определит, что содержимое не является изображением. Полный список соответствий расширений и MIME-типов ведётся в репозитории Apache HTTPD.
Несколько расширений через запятую работают как «или»:
$request->validate([
'document' => ['required', 'file', 'mimes:pdf,doc,docx,odt'],
]);
mimetypes – проверка по MIME напрямую
Когда нужен контроль на уровне конкретных MIME-строк, а не расширений:
$request->validate([
'report' => ['required', 'file', 'mimetypes:application/pdf,application/x-pdf'],
]);
Разница: mimes:pdf работает через маппинг расширения к MIME-типу, а mimetypes:application/pdf проверяет MIME напрямую. На практике для PDF результат одинаковый, но для нестандартных форматов mimetypes даёт прямой контроль, без промежуточной таблицы соответствий.
Поддерживаются wildcards для целых категорий:
$request->validate([
'media' => ['required', 'file', 'mimetypes:image/*'],
]);
Wildcard image/* пропустит JPEG, PNG, GIF, WebP и любой другой формат, чей MIME начинается с image/.
Расширение файла: extensions
В отличие от mimes, правило extensions смотрит только на расширение в имени файла:
$request->validate([
'spreadsheet' => ['required', 'file', 'extensions:xlsx,csv'],
]);
Само по себе это правило небезопасно – расширение легко подменить. Всегда комбинируйте extensions с mimes или mimetypes:
$request->validate([
'contract' => [
'required',
'file',
'extensions:pdf',
'mimes:pdf',
],
]);
Такая пара гарантирует: расширение соответствует ожидаемому, а содержимое действительно является PDF. На практике extensions нужен, когда система после сохранения файла опирается на его расширение – например, для выбора иконки или определения обработчика.
MIME-тип и расширение – разные вещи
Частая путаница: mimes якобы проверяет расширение. Нет. mimes:png проверяет, что содержимое файла соответствует MIME-типу image/png. Файл может называться photo.txt, но если внутри валидный PNG – правило mimes:png его пропустит.
Обратная ситуация: extensions:png пропустит файл virus.png, даже если внутри исполняемый код. Именно поэтому для строгой проверки нужны оба правила вместе:
$request->validate([
'avatar' => ['required', 'file', 'mimes:png', 'extensions:png'],
]);
Для форм загрузки аватаров, документов и вложений это рекомендуемый подход. Для внутренних API, где клиент контролируемый, достаточно одного mimes. В каких случаях расхождение между MIME и расширением создаёт реальную проблему? Когда приложение после загрузки определяет поведение по расширению файла: генерирует превью для .pdf, показывает галерею для .jpg, запускает импорт для .csv. Если расширение не совпадает с содержимым, обработчик получит неожиданный формат.
Размер файла
Размер проверяется в килобайтах. max задаёт верхнюю границу, min – нижнюю, size – точное значение:
$request->validate([
'photo' => ['required', 'file', 'max:2048'], // до 2 MB
'archive' => ['required', 'file', 'min:1', 'max:51200'], // 1 KB – 50 MB
'firmware' => ['required', 'file', 'size:4096'], // ровно 4 MB
]);
Правило between тоже работает для файлов:
$request->validate([
'backup' => ['required', 'file', 'between:100,10240'], // 100 KB – 10 MB
]);
Максимальный размер загружаемого файла ограничен не только валидацией, но и настройками PHP. Если upload_max_filesize в php.ini равен 2M, файл в 5 MB даже не дойдёт до валидатора – PHP отбросит его раньше.
Лимиты PHP и веб-сервера
Директивы php.ini, влияющие на загрузку:
; php.ini
upload_max_filesize = 10M ; максимум для одного файла
post_max_size = 20M ; максимум всего POST-запроса
memory_limit = 128M ; общий лимит памяти процесса
post_max_size должен быть больше upload_max_filesize, потому что POST-запрос содержит не только файл, но и текстовые поля формы. Правило max в валидаторе должно быть меньше или равно upload_max_filesize. Иначе PHP отклонит запрос до того, как Laravel его обработает, и пользователь увидит невнятную ошибку вместо понятного сообщения валидатора.
Для Nginx добавьте client_max_body_size:
server {
client_max_body_size 20m;
}
Apache ограничивает через LimitRequestBody, но по умолчанию лимит не установлен. Всегда синхронизируйте лимиты на всех уровнях: веб-сервер, PHP и валидатор Laravel.
Fluent-билдер File
Вместо перечисления строковых правил – цепочка методов через Illuminate\Validation\Rules\File:
use Illuminate\Validation\Rules\File;
$request->validate([
'resume' => [
'required',
File::types(['pdf', 'doc', 'docx'])
->min('10kb')
->max('5mb'),
],
]);
Размеры принимают суффиксы kb, mb, gb, tb. Это читается намного лучше, чем голые килобайты: max('5mb') вместо max:5120.
Билдер комбинируется с обычными строковыми правилами:
$request->validate([
'scan' => [
'required',
File::types(['pdf', 'png', 'jpg'])
->min('50kb')
->max('15mb'),
'extensions:pdf,png,jpg',
],
]);
Для условной обязательности файла билдер работает вместе с sometimes и nullable:
$request->validate([
'license' => [
'sometimes',
File::types(['pdf'])->max('10mb'),
],
]);
Здесь sometimes означает: проверять правила только если поле вообще присутствует в запросе. Подходит для формы редактирования, где файл необязательно загружать заново.
Валидация изображений
Правило image допускает форматы jpg, jpeg, png, bmp, gif, webp:
$request->validate([
'avatar' => ['required', 'image', 'max:3072'],
]);
SVG по умолчанию запрещён из-за возможности внедрения JavaScript (XSS-вектор). Если SVG необходим:
$request->validate([
'icon' => ['required', 'image:allow_svg', 'max:512'],
]);
HEIC (формат фотографий iPhone) не входит в список image – для его приёма используйте mimes:heic,heif вместо правила image. При этом dimensions тоже не сможет прочитать размеры HEIC, потому что PHP-функция getimagesize() его не поддерживает. Если нужно проверять размеры HEIC, придётся конвертировать файл перед валидацией или использовать стороннюю библиотеку вроде intervention/image.
File::image() – fluent-вариант
use Illuminate\Validation\Rules\File;
$request->validate([
'cover' => [
'required',
File::image()
->min('100kb')
->max('8mb'),
],
]);
SVG через билдер:
File::image(allowSvg: true)->max('1mb')
Подробнее о базовых правилах – в обзорной статье.
Размеры изображения: dimensions
Для контроля ширины, высоты и соотношения сторон используется dimensions:
$request->validate([
'banner' => [
'required',
'image',
'dimensions:min_width=600,min_height=200,max_width=1920,max_height=1080',
],
]);
Строковый синтаксис неудобен для чтения. Rule::dimensions() выразительнее:
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\File;
$request->validate([
'product_photo' => [
'required',
File::image()
->max('5mb')
->dimensions(
Rule::dimensions()
->minWidth(800)
->minHeight(600)
->ratio(4 / 3)
),
],
]);
Пропорции задаются дробью (3/2) или числом (1.5). Подходит для интернет-магазинов, где карточки товаров должны иметь единый формат.
Точные размеры и соотношения сторон
Для аватаров часто нужен строго квадратный формат:
Rule::dimensions()
->width(300)
->height(300)
width и height без min/max требуют ровно указанное количество пикселей. Для баннеров можно задать только соотношение, не фиксируя пиксели:
Rule::dimensions()->ratio(16 / 9)
Это пропустит и 1920x1080, и 1280x720, и 3840x2160 – главное, чтобы пропорция была 16:9. Параметры можно комбинировать: минимальные размеры с соотношением одновременно.
Валидация PDF
PDF – один из самых частых форматов для загрузки документов. Базовый вариант:
$request->validate([
'invoice' => ['required', 'file', 'mimes:pdf', 'max:10240'],
]);
Полная проверка с контролем расширения:
$request->validate([
'contract' => [
'required',
File::types(['pdf'])
->min('1kb')
->max('20mb'),
'extensions:pdf',
],
]);
PDF со сканами весят значительно больше текстовых – одностраничный скан может занимать 3-5 MB, а многостраничный договор со сканированными подписями легко выходит за 20 MB. Учитывайте это при выборе лимита. И не забудьте синхронизировать upload_max_filesize в php.ini с вашим правилом max.
Excel и офисные форматы
XLSX-файлы имеют MIME-тип application/vnd.openxmlformats-officedocument.spreadsheetml.sheet. Писать это каждый раз неудобно, и mimes принимает короткие имена:
$request->validate([
'price_list' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:5120'],
]);
Для старых .xls MIME будет application/vnd.ms-excel. Правило mimes:xls обработает это корректно. Через билдер:
$request->validate([
'report' => [
'required',
File::types(['xlsx', 'csv'])
->max('10mb'),
'extensions:xlsx,csv',
],
]);
С CSV есть нюанс: технически это текстовый файл, и MIME-гессер может определить тип как text/plain, а не text/csv. Правило mimes:csv в таком случае не сработает. Надёжнее проверять через extensions:csv, а структуру содержимого валидировать уже после загрузки – парсингом через fgetcsv() или пакет вроде league/csv.
Видеофайлы
Для видео удобнее mimetypes с конкретными форматами:
$request->validate([
'clip' => [
'required',
'file',
'mimetypes:video/mp4,video/mpeg,video/quicktime,video/webm',
'max:102400', // 100 MB
],
]);
Если нужно принять любое видео без ограничения формата:
$request->validate([
'recording' => ['required', 'file', 'mimetypes:video/*', 'max:204800'],
]);
Для видео критично настроить лимиты PHP и Nginx – 200 MB файл не загрузится при дефолтных 2M в upload_max_filesize. Загрузка больших видео обычно реализуется через chunk upload (библиотеки вроде pion/laravel-chunk-upload), а не через стандартную HTML-форму. Chunk upload разбивает файл на части по 1-5 MB и отправляет каждую отдельным запросом, обходя лимиты PHP. Валидация в этом случае происходит на уровне каждого чанка (размер, порядковый номер) и финально – после сборки полного файла (MIME-тип, общий размер).
Множественная загрузка
HTML-атрибут multiple позволяет выбрать несколько файлов:
<input type="file" name="photos[]" multiple>
Валидация через wildcard *:
$request->validate([
'photos' => ['required', 'array', 'max:10'],
'photos.*' => ['file', 'image', 'max:5120'],
]);
Первое правило ограничивает количество элементов массива (не более 10 файлов), второе – проверяет каждый файл отдельно. Суммарный размер запроса контролируется через post_max_size в php.ini – если пользователь загрузит 10 фото по 5 MB, POST-тело составит ~50 MB.
Типы файлов в массиве можно валидировать точно так же:
$request->validate([
'documents' => ['required', 'array', 'min:1', 'max:5'],
'documents.*' => [
'file',
'mimes:pdf,doc,docx',
'max:10240',
'extensions:pdf,doc,docx',
],
]);
Проверка общего размера массива файлов
Встроенного правила для суммарного размера нет. Решение – пользовательское правило:
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class MaxTotalSize implements ValidationRule
{
public function __construct(
private int $maxKilobytes
) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (false === is_array($value)) {
return;
}
$total = array_sum(array_map(
fn ($file) => $file->getSize() / 1024,
$value
));
if ($total > $this->maxKilobytes) {
$fail("Суммарный размер файлов не должен превышать {$this->maxKilobytes} КБ.");
}
}
}
$request->validate([
'photos' => ['required', 'array', new MaxTotalSize(20480)],
'photos.*' => ['image', 'max:5120'],
]);
Условная обязательность файла
Не всегда файл обязателен. На формах типа «Подать заявку» документ может требоваться только для определённых категорий. Правило exclude_unless полностью убирает поле из валидации, когда условие не выполнено:
$request->validate([
'type' => ['required', 'in:individual,company'],
'passport_scan' => [
'exclude_unless:type,individual',
'required',
'file',
'mimes:pdf,jpg,png',
'max:10240',
],
'registration_cert' => [
'exclude_unless:type,company',
'required',
'file',
'mimes:pdf',
'max:20480',
],
]);
Для физических лиц обязателен скан паспорта, для юридических – свидетельство о регистрации. exclude_unless не только снимает обязательность, но и полностью исключает поле из валидированных данных, если условие не совпадает. Это отличает его от required_if, который делает поле необязательным, но остальные правила в цепочке всё равно сработают, если файл передан.
Для переключателя «есть/нет вложений» подходит exclude_if:
$request->validate([
'has_attachments' => ['required', 'boolean'],
'files' => ['exclude_if:has_attachments,false', 'required', 'array'],
'files.*' => ['file', 'max:5120'],
]);
Form Request для загрузки файлов
Сложные формы с несколькими типами файлов лучше валидировать через Form Request:
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\File;
class StoreProductRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'main_image' => [
'required',
File::image()
->max('5mb')
->dimensions(
Rule::dimensions()->minWidth(600)->minHeight(400)
),
],
'gallery' => ['nullable', 'array', 'max:20'],
'gallery.*' => [
File::image()->max('8mb'),
'extensions:jpg,jpeg,png,webp',
],
'manual' => [
'nullable',
File::types(['pdf'])->max('50mb'),
],
];
}
public function messages(): array
{
return [
'main_image.max' => 'Главное фото не должно превышать 5 МБ.',
'gallery.max' => 'Максимум 20 фотографий в галерее.',
'gallery.*.max' => 'Каждое фото галереи – не более 8 МБ.',
];
}
}
В контроллере файлы уже провалидированы:
public function store(StoreProductRequest $request)
{
$path = $request->file('main_image')->store('products', 'public');
foreach ($request->file('gallery', []) as $photo) {
$photo->store('products/gallery', 'public');
}
}
Обновление записи с файлами
При редактировании файл обычно необязателен – пользователь может обновить только текстовые поля, а изображение оставить прежним. Типичный подход:
class UpdateProductRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'main_image' => [
'sometimes',
File::image()
->max('5mb')
->dimensions(
Rule::dimensions()->minWidth(600)->minHeight(400)
),
],
];
}
}
sometimes говорит валидатору: если поля main_image нет в запросе, пропустить все правила для него. Это отличается от nullable, который допускает пустое значение.
Тестирование загрузки файлов
Laravel предоставляет UploadedFile::fake() для создания тестовых файлов без реальных данных:
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
public function test_product_image_upload(): void
{
Storage::fake('public');
$file = UploadedFile::fake()->image('photo.jpg', 800, 600)->size(2048);
$response = $this->post('/products', [
'name' => 'Test Product',
'main_image' => $file,
]);
$response->assertRedirect();
Storage::disk('public')->assertExists('products/' . $file->hashName());
}
Метод image() принимает имя файла, ширину и высоту. Метод size() задаёт размер в килобайтах. Для не-изображений используйте create():
$pdf = UploadedFile::fake()->create('contract.pdf', 500, 'application/pdf');
$xlsx = UploadedFile::fake()->create('report.xlsx', 200, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
Тест на отклонение слишком большого файла:
public function test_rejects_oversized_image(): void
{
$file = UploadedFile::fake()->image('huge.jpg')->size(20000); // 20 MB
$response = $this->post('/products', [
'name' => 'Test',
'main_image' => $file,
]);
$response->assertSessionHasErrors('main_image');
}
Storage::fake() подменяет диск на временную директорию, которая очищается после теста. Это гарантирует, что тесты не засоряют файловую систему и не зависят друг от друга. Для проверки dimensions в тестах помните, что UploadedFile::fake()->image() создаёт реальное изображение с указанными размерами, поэтому правило dimensions корректно их считает.
Кастомные сообщения об ошибках
Стандартные сообщения валидатора для файловых правил технические: «The photo field must be a file of type: jpg, png.» Для формы, которую видят обычные пользователи, лучше написать свои:
$request->validate([
'photo' => ['required', 'image', 'max:5120', 'dimensions:min_width=400'],
], [
'photo.required' => 'Загрузите фотографию.',
'photo.image' => 'Файл должен быть изображением (JPG, PNG, GIF, WebP).',
'photo.max' => 'Фото не должно превышать 5 МБ.',
'photo.dimensions' => 'Минимальная ширина фото – 400 пикселей.',
]);
В Form Request то же самое делается через метод messages(). Подробнее о работе с сообщениями валидации – в обзорной статье.
Безопасность загруженных файлов
Загруженный файл хранится во временной директории PHP (sys_get_temp_dir()) до окончания запроса. Если валидация прошла, но store() или move() не вызваны – файл удалится автоматически.
При сохранении не используйте оригинальное имя без обработки:
// getClientOriginalName() может содержать ../path/traversal
$request->file('doc')->storeAs('docs', $request->file('doc')->getClientOriginalName());
// hashName() генерирует уникальное имя без пользовательского ввода
$request->file('doc')->store('docs', 'public');
Метод store() создаёт уникальное хеш-имя, исключая конфликты и path traversal через подставленное имя файла. Если нужно сохранить оригинальное имя для отображения пользователю, храните его в базе данных отдельно от физического имени на диске.
Ещё один момент – публичный доступ. Файлы на диске public доступны через URL напрямую. Конфиденциальные документы (договоры, паспорта) должны храниться на приватном диске и отдаваться через контроллер с проверкой прав:
public function download(Document $document)
{
$this->authorize('view', $document);
return Storage::download($document->path, $document->original_name);
}
Частые ошибки
Пропущено правило file. Без file в цепочке правил поле может содержать строку, и mimes выбросит исключение:
// mimes получит строку вместо UploadedFile и выбросит TypeError
$request->validate(['doc' => ['mimes:pdf']]);
// file отсекает не-файлы до запуска mimes
$request->validate(['doc' => ['file', 'mimes:pdf']]);
max в килобайтах, а не мегабайтах. Частая ловушка, особенно когда переходишь с размера строк (символы) на файлы (килобайты):
// 5 КБ, а не 5 МБ
$request->validate(['photo' => ['file', 'max:5']]);
// 5 МБ = 5120 КБ
$request->validate(['photo' => ['file', 'max:5120']]);
Fluent-билдер решает проблему: File::image()->max('5mb') – двусмысленности нет.
php.ini перекрывает валидацию. Если upload_max_filesize = 2M, файл в 10 MB не дойдёт до Laravel. PHP вернёт пустой $_FILES, и валидатор сообщит, что обязательное поле не заполнено – вместо ошибки о размере. Диагностировать это сложно, потому что сообщение об ошибке не указывает на реальную причину. Синхронизируйте настройки php.ini с правилами валидации.
SVG и правило image. image блокирует SVG. Если ожидаете SVG-иконки от дизайнеров, используйте image:allow_svg или File::image(allowSvg: true). Но SVG может содержать JavaScript, поэтому отдавайте такие файлы с заголовком Content-Type: image/svg+xml и рассмотрите санитизацию через библиотеку вроде enshrined/svg-sanitize.
Wildcard mimetypes:video/* пропускает аудио с видеодорожкой. Некоторые аудиофайлы (например, .m4a) могут иметь MIME-тип video/mp4, если контейнер это допускает. Для строгой проверки перечисляйте конкретные типы вместо wildcard.
dimensions не работает без image. Правило dimensions предполагает, что файл – изображение. Без image или File::image() в цепочке правил dimensions может не сработать корректно:
// dimensions без image может получить не-изображение
$request->validate(['photo' => ['file', 'dimensions:min_width=100']]);
// image гарантирует, что getimagesize() получит картинку
$request->validate(['photo' => ['image', 'dimensions:min_width=100']]);