Валидация в Laravel: с чего начать
Когда форма приходит на сервер, разработчик задаёт себе один и тот же вопрос: где проверять, что email действительно email, что заголовок не пустой, что пользователь не подсунул чужой id. Laravel предлагает три ответа на этот вопрос - три разных точки входа, и выбор между ними определяет, как будет выглядеть код контроллера через полгода.
Ниже разберём базовый flow: как валидировать форму в Laravel, как принять входные данные, как проверить request в нужном месте, как отклонить неверные значения и как вернуть пользователю осмысленные сообщения об ошибках. Примеры будут на чистом контроллере, на FormRequest и на фасаде Validator, плюс отдельные секции про вывод ошибок в Blade, ответы для AJAX-форм и тестирование валидации. Цель - не справочник по правилам, а карта вариантов и подводных камней, которые встречаются в каждом среднем проекте.
Три точки входа
Любой проект на Laravel рано или поздно использует все три варианта валидации запроса:
$request->validate()- метод-функция валидации на объекте Request. Самый короткий путь, удобен для маленьких форм и быстрой проверки данных в Laravel.FormRequest- отдельный класс валидации с правилами и authorize-логикой. Подходит для всего, что больше двух полей или повторяется в нескольких контроллерах.Validator::make()- фасад, возвращающий валидатор-объект напрямую. Нужен, когда правила формируются динамически или валидация отделена от HTTP-цикла (CLI-команда, очередь, ручной запуск из сервиса).
Под капотом все три собирают один и тот же объект Illuminate\Validation\Validator. Разница только в том, кто бросает исключение и кто решает, что делать с ошибками.
Короткое правило: одна форма на 2-3 поля - $request->validate() прямо в контроллере. Та же форма с авторизацией, кастомными сообщениями или повторным использованием в нескольких эндпоинтах - FormRequest. Валидация без HTTP (импорт CSV, обработка событий очереди, проверка данных в сервисе) - Validator::make(). На границе между сценариями переписывание стоит дёшево: правила и сообщения переезжают между точками входа без изменений.
Первый пример: validate в контроллере
Самый частый сценарий - валидация прямо в методе контроллера. Метод validate() берёт массив правил, проверяет тело запроса и возвращает массив отвалидированных значений. Так выглядит каноничный пример валидации в Laravel:
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'body' => ['required', 'string', 'min:50'],
'tags' => ['array', 'max:5'],
'tags.*' => ['string', 'distinct'],
]);
Post::create($validated);
return redirect()->route('posts.index');
}
Если хотя бы одно правило не прошло, Laravel бросает Illuminate\Validation\ValidationException. Дальше поведение зависит от типа запроса: обычная HTML-форма получает редирект на предыдущую страницу с ошибками во flash-сессии, а XHR-запрос (или запрос с заголовком Accept: application/json) - JSON-ответ с кодом 422.
Поведение фреймворка целиком зависит от того, кем он считает клиента: браузером с HTML-формой или JSON-консьюмером. Самый явный способ попасть в JSON-ветку - поставить заголовок Accept: application/json на запрос. XHR-клиенты, добавляющие X-Requested-With: XMLHttpRequest (jQuery, старые версии axios), тоже трактуются как JSON. А голый fetch() без заголовков ни одного из сигналов не шлёт - такой запрос получит редирект 302 как обычная форма, и тест на статус 422 в этом случае провалится.
Извлекаем правила в FormRequest
Когда правил больше пяти и хотя бы один контроллер использует те же поля повторно, пора создавать FormRequest - выделенный класс валидации:
php artisan make:request StorePostRequest
В сгенерированном классе два метода: rules() и authorize(). Метод authorize() решает, имеет ли текущий пользователь право выполнить действие. Stub команды make:request генерирует authorize() с return false - это сделано умышленно, чтобы разработчик принял решение явно. Без правки return false контроллер сразу получит 403, и это частая причина «валидация не работает». Если же authorize() вообще удалить из класса, сработает дефолт базового FormRequest::authorize(), который возвращает true. rules() возвращает массив правил:
namespace App\Http\Requests;
use App\Models\Post;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('create', Post::class) ?? false;
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'body' => ['required', 'string', 'min:50'],
'category_id' => ['required', Rule::exists('categories', 'id')],
];
}
public function messages(): array
{
return [
'title.required' => 'Заголовок обязателен.',
'body.min' => 'Тело поста должно быть не короче 50 символов.',
];
}
public function attributes(): array
{
return ['category_id' => 'категория'];
}
protected function prepareForValidation(): void
{
$this->merge([
'title' => trim((string) $this->input('title')),
]);
}
}
Контроллер становится кошмарно коротким:
public function store(StorePostRequest $request): RedirectResponse
{
$post = Post::create($request->validated());
return redirect()->route('posts.show', $post);
}
Type-hint StorePostRequest сообщает контейнеру: разреши класс, прогон валидации сделай до того, как метод получит управление. Если правила не прошли, контроллер не вызывается. Так выглядит валидация запроса в Laravel через FormRequest: правила хранятся в одном месте, а сам метод занимается только бизнес-логикой.
prepareForValidation() запускается до правил и удобен для нормализации: trim, привод регистра, парсинг JSON-строк. $this->merge() подменяет только указанные ключи; $this->replace() заменяет всё целиком, что нужно реже.
Когда после валидации требуется кросс-проверка нескольких полей разом (например, «общая стоимость заказа должна совпадать с суммой строк»), удобнее всего использовать метод after() у FormRequest. Он принимает массив замыканий или invokable-классов и получает на вход сам валидатор:
use Illuminate\Validation\Validator;
public function after(): array
{
return [
function (Validator $validator) {
// after() запускается даже если базовые правила провалились,
// поэтому проверяем валидность нужных полей перед обращением.
if ($validator->errors()->hasAny(['total', 'items'])) {
return;
}
$sum = collect($this->input('items', []))
->sum(fn ($item) => $item['qty'] * $item['price']);
if ($sum !== (int) $this->input('total')) {
$validator->errors()->add('total', 'Сумма строк не совпадает с total.');
}
},
];
}
Guard на первой строке - не перестраховка. Без него callback побежит на сырых данных: если items пришёл строкой или скаляром, обращение $item['qty'] либо выдаст Warning об illegal offset, либо упадёт с TypeError (поведение зависит от версии PHP), либо просто посчитает сумму неверно. Ошибка от cross-валидации добавится поверх уже существующих сообщений и собьёт пользователя.
Атрибуты PHP 8 заменяют большинство ручных переопределений: #[StopOnFirstFailure] останавливает валидацию на первой ошибке всего FormRequest, #[RedirectTo('/dashboard')] меняет адрес редиректа после провала, #[ErrorBag('login')] отправляет ошибки в именованный bag без явного validateWithBag. Подробнее про FormRequest, passedValidation и эти атрибуты - в form-requests.
Validator::make для ручного контроля
Когда нужна полная свобода - например, два набора правил для create и update в одном методе или валидация без HTTP-запроса вообще, - берём фасад напрямую:
use Illuminate\Support\Facades\Validator;
$validator = Validator::make($data, [
'name' => ['required', 'string', 'max:120'],
'email' => ['required', 'email'],
]);
if ($validator->fails()) {
foreach ($validator->errors()->all() as $message) {
logger()->warning($message);
}
return back()->withErrors($validator)->withInput();
}
$clean = $validator->validated();
Принципиальное отличие от $request->validate(): фасад не бросает исключение сам. Если не вызвать ->validate() или не проверить ->fails() вручную, валидатор молча соберёт ошибки, а код побежит дальше с грязными данными. Это типичная ловушка при переходе с короткого синтаксиса на Validator::make.
Цепочка back()->withErrors($validator)->withInput() сама положит ошибки и старый ввод во флэш-сессию, так что в следующем запросе переменная $errors будет заполнена, а old() вернёт значения формы. Если хочется получить поведение validate() в стиле фасада, на самом валидаторе тоже есть метод validate(): он бросит ValidationException, а Laravel сам разрулит редирект или JSON.
Метод after() на валидаторе работает так же, как в FormRequest: добавляет дополнительные проверки уже после прогона базовых правил. Это удобно для кросс-валидации нескольких полей или для запросов в БД, которые нет смысла делать, пока базовая структура запроса не подтверждена.
validated() возвращает не всё, что вы думаете
Метод validated() отдаёт только ключи, которые присутствуют в массиве правил. Поле, которое пришло в запросе, но не упомянуто в rules(), в результат не попадает:
// rules: ['title', 'body']
// в request: title, body, draft (без правила)
$clean = $request->validated();
// $clean содержит только title и body, draft потерян
После рефакторинга, в котором кто-то убрал правило для поля, оно молча исчезнет из всех вызовов Model::create($validated). Если поле всё равно нужно дальше в коде, явно прочитайте его через $request->input('draft') или хотя бы добавьте nullable в rules(). Метод safe() отдаёт объект Illuminate\Support\ValidatedInput с теми же провалидированными ключами: пока данные не мутировали, safe()->all() равен validated() по составу. Сверху - удобные only(), except(), merge() и доступ по ключу как у массива. Стоит вызвать safe()->merge(['extra' => $value])->all(), и extra появится в результате - набор станет шире, чем у validated(). То есть safe() - не способ обойти rules(), а fluent-обёртка для последующих выборок и явных дополнений.
unique при обновлении сущности
Самая частая ошибка с правилом unique - оно проверяет всё подряд, включая саму редактируемую запись. На update пользователь оставляет свой email прежним, валидатор находит совпадение в БД и падает: «email уже занят». Решение - Rule::unique с вызовом ignore():
public function rules(): array
{
return [
'email' => [
'required', 'email',
Rule::unique('users', 'email')->ignore($this->route('user')),
],
'sku' => [
'required',
Rule::unique('products', 'sku')->ignore($this->route('product')),
],
];
}
ignore() принимает либо id, либо целую модель: Laravel автоматически достанет ключ. По умолчанию исключение ищется по колонке id. Если у таблицы нестандартное имя PK (например, uuid), укажите его вторым аргументом: ignore($user->uuid, 'uuid'). Без этого правило не найдёт «свою» запись и продолжит считать её дубликатом самой себя.
Без ignore() правило unique отвергает запись на её собственном уникальном значении: «email уже существует» - и менять его не нужно, потому что он принадлежит этой же записи. Это поведение unique по определению, не баг.
Никогда не передавайте в
ignore()произвольный ввод от пользователя ($request->idи подобное). Только системный идентификатор из роутера или модели - иначе откроете SQL-инъекцию.
Дополнительные условия задаются через where():
Rule::unique('users')
->where(fn ($q) => $q->where('account_id', $this->user()->account_id))
->ignore($this->route('user'))
Если таблица использует soft deletes, по умолчанию удалённые записи всё равно участвуют в проверке уникальности. Метод withoutTrashed() исключает их:
Rule::unique('users')->withoutTrashed()->ignore($this->route('user'))
Композитный ключ (например, уникальность пары account_id + slug) встроенным синтаксисом не задаётся - нужен Rule::unique()->where() с дополнительным условием. Подробнее про unique, exists, withoutTrashed и составные ключи - в unique-exists.
Вывод ошибок и старого ввода в Blade
Переменная $errors в шаблонах - не массив, а контейнер Illuminate\Support\ViewErrorBag. Под капотом он держит именованные MessageBag-объекты и проксирует часть вызовов в default-bag через __call. Поэтому $errors->any(), $errors->all(), $errors->first('email') работают напрямую; если helper-функция или сервис ожидают именно MessageBag, передавайте туда $errors->getBag('default').
Сама переменная попадает во view через middleware ShareErrorsFromSession, который зарегистрирован в группе web. Это значит, что внутри view, отрендеренной из web-роута, $errors определена всегда и проверять isset($errors) смысла нет - используем $errors->any():
@if ($errors->any())
<ul class="form-errors">
@foreach ($errors->all() as $message)
<li>{{ $message }}</li>
@endforeach
</ul>
@endif
Для отдельного поля удобна директива @error:
<input name="title" value="{{ old('title') }}" class="@error('title') invalid @enderror">
@error('title')
<small class="text-danger">{{ $message }}</small>
@enderror
@error('field') открывает условный блок: внутри становится доступна переменная $message с первой ошибкой по полю, её и выводят через {{ $message }}. Если правил несколько и нужно показать все ошибки сразу - $errors->get('field') возвращает массив:
@if ($errors->has('email'))
<ul>
@foreach ($errors->get('email') as $message)
<li>{{ $message }}</li>
@endforeach
</ul>
@endif
Хелпер old() достаёт значение из flash-сессии, куда Laravel автоматически кладёт предыдущий ввод при редиректе с ошибкой. Для file-полей old() бесполезен: загруженные файлы не флэшатся.
Если view рендерится вне группы web (например, из API-роута без web-middleware, из console-команды или из mailable), ShareErrorsFromSession не отрабатывает и $errors в шаблоне просто не объявлена. В таких контекстах либо передавайте MessageBag явно через view(..., compact('errors')), либо проверяйте @if (isset($errors) && $errors->any()).
old() для select и textarea
<input type="text"> - самый простой случай: value="{{ old('field') }}". Но половина формы состоит из <select> и <textarea>, и тут шаблон другой.
Для <textarea> старое значение идёт между тегами, а не в атрибут:
<textarea name="body" rows="6">{{ old('body') }}</textarea>
Если поле редактируется (форма update), удобно подставить old() со вторым аргументом - fallback из модели:
<textarea name="body" rows="6">{{ old('body', $post->body) }}</textarea>
Для <select> атрибут selected нужен на правильном <option>. Проверять вручную через == неудобно - есть Blade-директива @selected:
<select name="status">
<option value="draft" @selected('draft' === old('status', $post->status))>Черновик</option>
<option value="published" @selected('published' === old('status', $post->status))>Опубликовано</option>
<option value="archived" @selected('archived' === old('status', $post->status))>Архив</option>
</select>
То же касается чекбоксов и радио - там директива @checked. Старое значение для file-полей не сохраняется: загруженные файлы нельзя «восстановить» из flash-сессии, поэтому пользователю придётся выбирать файл заново.
Ошибки элементов массива
Когда правила работают с массивом (comments.*.body, items.0.name), сообщения приходят с теми же точечными ключами. Достать ошибку по индексу можно тем же ключом:
@foreach ($items as $i => $item)
<input
name="items[{{ $i }}][name]"
value="{{ old('items.'.$i.'.name', $item->name) }}"
class="@error('items.'.$i.'.name') invalid @enderror"
>
@error('items.'.$i.'.name')
<small>{{ $message }}</small>
@enderror
@endforeach
Для всех ошибок по wildcard-пути - метод $errors->get():
@foreach ($errors->get('comments.*.body') as $messages)
@foreach ($messages as $message)
<p class="text-danger">{{ $message }}</p>
@endforeach
@endforeach
{{-- Только первое сообщение по любому из comments.*.body --}}
{{ $errors->first('comments.*.body') }}
В сообщении правила можно вывести позицию проблемного элемента через плейсхолдеры :index (с нуля) и :position (с единицы):
public function rules(): array
{
return [
'photos.*.description' => ['required', 'string'],
];
}
public function messages(): array
{
return [
'photos.*.description.required' => 'Опишите фото №:position.',
];
}
Пользователь увидит «Опишите фото №2» вместо «photos.1.description обязательно» - это куда дружелюбнее. Для глубоко вложенных структур есть :second-position, :third-position и так далее. Подробнее про массивы и JSON - в arrays-json, а про вывод и кастомизацию сообщений - в error-messages.
Несколько форм на одной странице
Если на странице две формы, которые валидируются независимо (логин и регистрация, фильтр и поиск, два модальных окна в админке), ошибки одной формы по умолчанию вылезут в другой - они кладутся в общий bag. Разводятся ошибки через именованные bag: $request->validateWithBag('register', [...]), для ручного валидатора - ->validateWithBag('login') или redirect()->withErrors($validator, 'profile'). В Blade ошибки извлекаются по имени: $errors->register->first('email') или @error('email', 'register'). Подробный пример с двумя формами на одной view, нюансы withErrors и взаимодействие с Validator::make - в error-messages.
JSON-ответ для API
Для XHR/JSON-запросов Laravel формирует payload автоматически:
{
"message": "The title field is required. (and 2 more errors)",
"errors": {
"title": ["The title field is required."],
"category_id": ["The selected category id is invalid."]
}
}
Ключ message - это первая ошибка плюс счётчик «и ещё N»; для UI лучше парсить объект errors, в нём массив сообщений на каждое поле. Вложенные правила приходят с точечными ключами: users.0.email, items.2.title. Frontend перебирает их и рисует подсказки рядом с нужными инпутами. На стороне Vue или React удобнее всего хранить ошибки в плоском объекте { field: messages[] } и сбрасывать значение по конкретному ключу при изменении инпута, чтобы пользователь видел исчезновение ошибки сразу после правки.
422 возвращается, когда Laravel считает клиента JSON-консьюмером. Самый явный сигнал - заголовок Accept: application/json; XHR-клиенты с X-Requested-With: XMLHttpRequest тоже распознаются. Если ни одного из сигналов нет, даже AJAX-запрос редиректится - частая причина «непонятного» поведения и в API-роутах, и в тестах.
Тестирование валидации
В feature-тесте проверка проста, если знать про заголовок Accept. HTML-сценарий идёт через post() и assertInvalid():
public function test_post_requires_title(): void
{
$this->actingAs(User::factory()->create())
->post('/posts', ['body' => str_repeat('a', 80)])
->assertStatus(302)
->assertInvalid(['title']);
}
API-эндпоинт тестируется через postJson(). Этот хелпер сам ставит заголовки Accept: application/json и Content-Type: application/json, поэтому маршрут вернёт JSON, а не редирект:
public function test_api_post_returns_422(): void
{
$this->actingAs(User::factory()->create(), 'sanctum')
->postJson('/api/posts', ['body' => 'короткое тело'])
->assertStatus(422)
->assertJsonValidationErrors(['title', 'body']);
}
Если в API-тесте использовать обычный post() без Accept-заголовка, маршрут вернёт редирект 302, а не 422 JSON. assertJsonValidationErrors упадёт с непонятным «No JSON in response», хотя правила корректные. Замена post на postJson или явная установка withHeaders(['Accept' => 'application/json']) - самый частый рефакторинг при превращении web-теста в API-тест.
Дополнительные проверки - assertJsonStructure(['errors' => ['title']]), assertJsonPath('errors.title.0', 'The title field is required.'). Сам класс FormRequest можно тестировать в изоляции, прогоняя validator(...)->fails() на разных входах, без HTTP-стэка.
Что часто ломается
authorize()возвращаетfalse- клиент получает 403, разработчик ищет проблему в правилах. Stub послеmake:requestгенерирует именноreturn false, чтобы решение об авторизации было явным; без правки или удаления метода это самая частая причина «валидация не работает».- Запрос на API без
Accept: application/jsonи безX-Requested-With: XMLHttpRequestредиректится. Заметно у гологоfetch(), в Postman без настроек и в feature-тестах черезpost(). validated()теряет поля, которых нет вrules(). После любой правки правил перепроверьте, чтоModel::create($validated)получает все нужные ключи.confirmedждёт парное поле{field}_confirmation. Если фронт прислал confirm-поле с другим именем (password_repeatвместоpassword_confirmation), валидатор не найдёт пары и упадёт с ошибкой подтверждения. Поменять имя на стороне фреймворка можно явным аргументом:'password' => 'confirmed:password_repeat'.- Несколько форм на странице без именованных bag - ошибки одной появляются в другой. Забытый второй аргумент
@error('field', 'bag')тоже скрывает сообщения. bailостанавливает прогон правил только для текущего поля после первой ошибки; чтобы остановить весь валидатор, нужен$validator->stopOnFirstFailure()или атрибут#[StopOnFirstFailure]на FormRequest.- Папка
lang/в новых проектах не создаётся - для русских сообщений по умолчанию её нужно опубликовать командойphp artisan lang:publish, иначе строки вlang/ru/validation.phpпросто некуда положить.
Дальше - отдельные кластеры по правилам (rules, strings-numbers, arrays-json, files), условные правила, кастомные правила, кастомизация сообщений и подробный разбор FormRequest.