<? phpukraine СПІВБЕСІДИ
Пошук по платформі
LARAVEL · JUNIOR ЧАСТО ПИТАЮТЬ

Як організувати валідацію: Form Request проти validate() у контролері?

Обидва шляхи запускають один і той самий Validator: `$request->validate()` робить це всередині методу контролера, Form Request — під час резолву аргументів, ще до входу в метод, і додає authorize(), prepareForValidation() і власні повідомлення. Інлайн — для двох-трьох простих правил, Form Request — щойно зʼявляються авторизація, нормалізація вхідних даних або повторне використання правил.

У який момент відпрацьовує Form Request — до контролера чи всередині нього?
Що станеться, якщо authorize() поверне false: помилка валідації чи 403?
Чому наш API на невалідні дані повертає 302, а не 422?
Ви зробили Form Request, а в базу все одно потрапило зайве поле — як так?
Валідація не пройшла, користувача повернуло на форму, але помилок на сторінці немає. Де шукати?
validation FormRequest ValidationException authorize

Обидва підходи роблять одну й ту саму роботу одним і тим самим класом: Illuminate\Validation\Validator отримує масив вхідних даних і масив правил, і при першій невідповідності кидає ValidationException. Різниця лише в тому, де цей виклик стоїть. $request->validate([...]) — це метод на реквесті, який виконується всередині тіла контролера, коли ви туди вже зайшли. Form Request — це клас-параметр методу, і Laravel валідує його раніше: коли роутер резолвить аргументи через сервіс-контейнер, спрацьовує трейт ValidatesWhenResolved, який після створення обʼєкта викликає validateResolved(). Тому при провалі в тіло методу керування взагалі не потрапляє — це не «магія», а просто інший момент виклику того самого валідатора.

Порядок усередині Form Request важливий і його часто питають: спершу prepareForValidation() (тут нормалізують вхід — обрізають пробіли, приводять slug, розбивають рядок на масив), потім authorize(), і лише потім будуються правила з rules(). Тобто якщо authorize() повернув false, правила навіть не запускаються, а користувач отримує AuthorizationException403, а не 422 зі списком помилок. Форму відповіді при провалі валідації обирає не контролер, а глобальний обробник винятків: для звичайного браузерного запиту це редірект back() із помилками та старим інпутом у сесії, для запиту з заголовком Accept: application/json — статус 422 і тіло з ключами message та errors. Звідси два класичних симптоми: «API повертає 302 замість 422» означає, що клієнт не надіслав Accept: application/json, а «редіректить назад, але помилок на сторінці немає» — що маршрут не в групі web, тобто немає сесії й middleware ShareErrorsFromSession, яке кладе $errors у в'ю.

Головна практична пастка не в тому, де стоїть виклик, а в тому, що валідація нічого не прибирає з реквесту. Form Request не є фільтром: після нього $request->all() містить рівно те саме, що надіслав клієнт, включно з полями, для яких правил не було. Тому Post::create($request->all()) лишається діркою, від якої рятує тільки $fillable, і правильний рядок — $request->validated() або $request->safe()->only([...]). Тут же живе друга пастка: validated() віддає лише ті ключі, на які були правила, і для масивів це означає, що без окремого рядка 'tags.*' => ['integer'] поле tags пройде перевірку array, але у validated() його не буде — форма мовчки збережеться без тегів.

Практичний критерій вибору простий і його варто озвучити саме як критерій, а не як «Form Request завжди краще». Інлайн validate() доречний, коли правил два-три, вони тривіальні й ніде більше не потрібні: пошук, фільтр у списку, підписка на розсилку. Form Request забирає задачу, щойно зʼявляється хоч одне з чотирьох: перевірка прав (authorize() замість if у контролері), нормалізація даних до перевірки (prepareForValidation()), кастомні повідомлення й назви полів (messages(), attributes()), або потреба перевикористати правила у двох місцях — типово Store*Request і Update*Request, де другий успадковує перший і додає Rule::unique(...)->ignore($this->route('post')). Додатково Form Request дає withValidator() для крос-польових перевірок і stopOnFirstFailure = true, коли не потрібен повний список помилок. Третій варіант — Validator::make() з фасаду — лишається для випадків, коли дані прийшли не з HTTP-запиту або треба самому вирішити, що робити з fails().

Межа підходу: Form Request прибитий до HTTP-запиту й контейнера, тож у консольній команді, черговій job чи сідері він не працює взагалі, а Livewire не проганяє його автоматично — там правила описує сам компонент або Form-обʼєкт (перевикористати можна через (new StorePostRequest)->rules()). Це нормально, поки ви памʼятаєте, що валідація — це перевірка межі, тобто «чи придатне те, що надіслав клієнт», а не заміна доменним інваріантам. Правило «сума замовлення не менша за нуль» має триматися незалежно від того, чи прийшли дані з форми, з імпорту CSV чи з консолі, тому його місце — у моделі або сервісі; Form Request же залишається тонким шаром, який ловить сміття на вході й повертає користувачу зрозумілі повідомлення.

final class StorePostRequest extends FormRequest
{
    // Викликається ДО rules(); false → AuthorizationException (403), правила не запускаються
    public function authorize(): bool
    {
        return $this->user()->can('create', Post::class);
    }

    /** @return array<string, list<mixed>> */
    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:120'],
            'slug' => ['required', 'alpha_dash', Rule::unique('posts', 'slug')],
            'published_at' => ['nullable', 'date'],     // без nullable порожнє поле впаде на date
            'tags' => ['array', 'max:5'],
            'tags.*' => ['integer', 'exists:tags,id'],  // без цього рядка tags не буде у validated()
        ];
    }

    // Нормалізація ДО перевірки: у rules() і в базу піде вже приведений slug
    protected function prepareForValidation(): void
    {
        $this->merge(['slug' => Str::slug((string) $this->input('slug'))]);
    }

    /** @return array<string, string> */
    public function messages(): array
    {
        return ['slug.unique' => 'Стаття з таким посиланням уже існує.'];
    }
}

final class PostController
{
    // Валідація пройшла ще до входу сюди: у методі лише чисті дані
    public function store(StorePostRequest $request): RedirectResponse
    {
        $post = Post::create($request->safe()->only(['title', 'slug', 'published_at']));
        $post->tags()->sync($request->validated('tags', []));  // ключа може не бути

        return to_route('posts.show', $post);
    }

    // Інлайн-варіант для двох простих правил: той самий Validator, лише інше місце виклику
    public function search(Request $request): View
    {
        $data = $request->validate(['q' => ['required', 'string', 'min:3']]);

        return view('posts.search', ['posts' => Post::search($data['q'])->paginate()]);
    }
}
Що Form Request валідується не в контролері, а в момент резолву залежності контейнером (трейт ValidatesWhenResolved), тому в тіло методу керування взагалі не переходить при провалі.
Що обидва варіанти кидають один і той самий ValidationException, а форму відповіді обирає обробник винятків: редірект назад із помилками в сесії для звичайного запиту і 422 з JSON для запиту з `Accept: application/json`.
Що authorize() виконується перед rules() і при false дає AuthorizationException → 403, а не 422.
Що працювати треба з `validated()`/`safe()`, а не з `$request->all()`: сам факт валідації нічого з реквесту не викидає.
Що для елементів масиву потрібне окреме правило `tags.*`, інакше поле не потрапить у validated().
Що prepareForValidation() нормалізує дані до перевірки, а не після, тож у rules() і в базу йде вже приведене значення.
Думати, що Form Request «фільтрує» реквест: після нього `$request->all()` і `$request->input('is_admin')` як були, так і лишились, тому `Post::create($request->all())` пропускає незаявлені поля.
Класти перевірку прав у rules() (наприклад, кастомним правилом), замість authorize() чи політики — і отримувати 422 там, де мало бути 403.
Викликати `$request->validate()` в контролері API і дивуватись 302: без заголовка `Accept: application/json` Laravel вважає запит браузерним і робить редірект назад.
Забути `nullable` для необовʼязкових полів: порожній `published_at` приходить як `null` або `''` і валиться на правилі `date`.
Писати правило `'tags' => ['array']` без `'tags.*' => [...]` — масив проходить валідацію, але `validated()` його не віддає.
Використовувати `$this->validate()` у Form Request або дублювати правила: одні в FormRequest, інші в контролері «про всяк випадок».
Виносити маршрут із групи `web` і чекати, що `$errors` зʼявиться у в'ю: помилки живуть у сесії, їх шарить middleware ShareErrorsFromSession.
ПОРАДА

Скажіть, де саме проходить межа: «інлайн `validate()` — коли правил два-три і вони більше ніде не потрібні; Form Request — коли зʼявляється авторизація, нормалізація вхідних даних, кастомні повідомлення або те саме правило потрібне в другому місці». І одразу додайте головне практичне: беру `validated()`, а не `all()`, бо валідація нічого з реквесту не викидає.

оновлено 4 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Form Request резолвиться контейнером до виклику методу (трейт ValidatesWhenResolved), тому при провалі керування в контролер не переходить. Але валідація нічого з реквесту не видаляє: `all()` лишається повним, і безпечні дані дає `validated()` або `safe()`. Валідатор в обох підходах один і той самий, а провал authorize() дає 403, а не 422.