Валидация в Laravel: с чего начать

Когда форма приходит на сервер, разработчик задаёт себе один и тот же вопрос: где проверять, что email действительно email, что заголовок не пустой, что пользователь не подсунул чужой id. Laravel предлагает три ответа на этот вопрос - три разных точки входа, и выбор между ними определяет, как будет выглядеть код контроллера через полгода.

Ниже разберём базовый flow: как валидировать форму в Laravel, как принять входные данные, как проверить request в нужном месте, как отклонить неверные значения и как вернуть пользователю осмысленные сообщения об ошибках. Примеры будут на чистом контроллере, на FormRequest и на фасаде Validator, плюс отдельные секции про вывод ошибок в Blade, ответы для AJAX-форм и тестирование валидации. Цель - не справочник по правилам, а карта вариантов и подводных камней, которые встречаются в каждом среднем проекте.

Три точки входа

Любой проект на Laravel рано или поздно использует все три варианта валидации запроса:

Под капотом все три собирают один и тот же объект 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-стэка.

Что часто ломается

Дальше - отдельные кластеры по правилам (rules, strings-numbers, arrays-json, files), условные правила, кастомные правила, кастомизация сообщений и подробный разбор FormRequest.