FormRequest: валідація форм в Laravel

FormRequest: валідація форм в Laravel

Якщо форма проста, і містить усього пару полів, то можна робити перевірку введення і в контролерах, але навіть в таких випадках особисто я надаю перевагу іншому підходу - FormRequest, тобто використанню класів, в яких описана логіка валідації.

Чому не в контролерах?

По-перше, якщо якийсь метод контролера виконує якесь конкретне завдання - нехай ним і займається, решта - не його проблема. Простіше кажучи, якщо метод призначений для збереження даних, нехай зберігає дані, а перевірку делегує комусь іншому. По-друге, подивимося на приклад валідації в методі store(), а щоб було наочніше, припустимо, що ми хочемо вивести якесь кастомне повідомлення про помилку:

public function store(Request $request)
{
    $request->validate([
        'title' => 'required'
    ], [
        'title.required' => 'Hey! You have to fill in the :attribute field.'
    ]);

    // ИЛИ
    $this->validate($request, [
        'title' => 'required'
    ], [
        'title.required' => 'Hey! You have to fill in the :attribute field.'
    ]);
}

 

Можна уявити, наскільки збільшиться код методу, якщо поле буде не одне, а набагато більше, що не рідкість. Як варіант - винести перевірку в окремий метод. Але зазвичай там, де є збереження, є і оновлення, і часто відповідні перевірки даних дещо відрізняються, тобто отримаємо не один метод, а два і більше. Звідси запитання - навіщо засмічувати контролер зайвим кодом, коли під рукою є вже готове рішення? Подивимося на нього.

 

FormRequest та його методи

Кілька слів щодо структури каталогів. За замовчуванням Laravel буде розміщувати всі створювані підкласи FormRequest у теці app/Http/Requests. Але не дуже комфортно, коли все скинуто в одну купу. Файли можна впорядковувати, наприклад, так:

|-Requests
    |-Category
    |   |- StoreCategory.php
    |   |- UpdateCategory.php
    |
    |-Post
    |   |-StorePost.php
    |   |-UpdatePost.php
...

 

Відповідно до вищесказаного створимо клас для перевірки даних форми нової категорії. Як завжди, скористаємося консоллю:

php artisan make:request Category/StoreCategory

 

В результаті буде створено файл app/Http/Requests/Category/StoreCategory.php з однойменним класом-нащадком FormRequest і двома методами - authorize() і rules().

Метод authorize()

Призначений для того, щоб перевірити, чи має користувач право відправляти форму. На час забудемо про категорії і обговоримо метод. Якщо мова йде про відправку форми зворотного зв'язку з фронту, яку можуть відправляти неавторизовані користувачі - просто міняємо false на true, надаючи це право всім бажаючим. Якщо ми говоримо про додавання коментарів, тобто юзер повинен бути залогіненим, метод може виглядати так:

public function authorize()
{
    return auth()->check();
}

 

Ну а якщо перевіряємо будь-яку форму в адмін-панелі, то ... вибачте, тупо ставимо true. Справа в тому, що якщо хтось зміг дістатися до цієї форми, то ми вже точно перевіряли його якимось admin middleware у роутах або контролері, а дублювати одне й те ж зовсім ні до чого.

Метод rules()

Тут як би "і так все зрозуміло", але є деякі цікаві моменти: як зробити валідацію масиву? А якщо кожен елемент масиву теж масив? Наприклад, перевіряємо користувача, і є не просто кілька телефонів, а й тип - мобільний, робочий, домашній. Оскільки ці питання стосуються більше правил, ніж безпосередньо методу, повернемося до них пізніше..

А поки подивимося, що можна зробити в такій ситуації: потрібно, щоб назва категорії була унікальною. При збереженні нової категорії з цим проблем немає. Просто в правилах пропишемо:

public function rules()
{
    return [
        'title' => 'required|unique:categories',
        ...
    ];
}

 

При оновленні даних ми так зробити вже не можемо, оскільки будуть перевірятися всі записи, у тому числі і той, що оновлюється. Тобто треба дати команду перевірити title на унікальність у всіх записах, крім поточного, що буде виглядати приблизно так:

...
use Illuminate\Validation\Rule;

class UpdateCategory extends FormRequest
{

    ...

    public function rules()
    {
        return [
            'title' => [
                'required',
                Rule::unique('categories')->ignore($this->route('id'))
            ],
            ...
        ];
    }
}

 

І ніби все добре, крім одного "але" - всі інші правила крім title дублюються. Вихід з ситуації - успадкувати UpdateCategory від StoreCategory. В останньому пропишемо всі правила, а в першому тільки ті, які відрізняються. Потім злитиємо масиви з методів rules() функцією array_merge(). Нагадаю: "якщо вхідні масиви мають однакові рядкові ключі, тоді кожне наступне значення буде замінювати попереднє". Тобто в UpdateCategory отримаємо прописану нами нову перевірку унікальності назви, доповнену усіма іншими правилами з StoreCategory. Ось повний код класу:

<?php

namespace App\Http\Requests\Category;

use App\Http\Requests\Category\StoreCategory;

class UpdateCategory extends StoreCategory
{
    public function authorize()
    {
        return true;
    }

    public function rules()
    {
        return array_merge(parent::rules(), [
            'title' => [
                'required',
                Rule::unique('categories')->ignore($this->route('id'))
            ]
        ]);
    }
}

 

Метод messages()

Цього методу немає в створеному класі, але він є в батьківському FormRequest, тобто можемо його перевизначити, і саме в ньому прописати кастомне повідомлення про помилки, наприклад:

public function messages()
{
    return [
        'title.required' => 'Hey! You have to fill in the :attribute field.'
    ];
}

 

Метод attributes()

Також перевизначимо метод з класу-батька. Навіщо це може бути потрібно? У повідомленнях про помилки замість :attribute підставляється назва поля. Тобто якщо взяти повідомлення з методу вище, то буде виведено:

Hey! You have to fill in the title field.

Але що робити, якщо замість title ми хочемо використовувати будь-яке інше слово або фразу? І знову приклад використання:

public function  attributes()
{
    return [
        'title' => 'category title'
    ];
}

 

Тепер в разі помилки побачимо:

Hey! You have to fill in the category title field.

 

Про правила та локалізацію

Повернемося до правил. Опишу пару ситуацій, які мені здалися цікавими.

Про першу згадував вище: у користувача кілька телефонів, причому під словом "телефон" я маю на увазі не просто номер, а сутність зі своїми властивостями - номер телефону, тип (мобільний, домашній), прив'язаний чи телефон до вайберу. Аналогічний випадок - робимо багатомовний сайт і є, наприклад, Post із загальними властивостями, як то id автора, id категорії, чи опублікований пост, дата створення. І до посту прив'язані переклади (PostTranslation) з властивостями: назва, текст поста, мета опис. І в тому, і в іншому випадку будемо мати масиви даних, які треба перевірити. І відразу подумаємо ось про що: в першому випадку ми хочемо, щоб користувач вказав хоча б один номер телефону, у другому - щоб були заповнені поля перекладу як мінімум на одну мову (інакше який сенс в пості?). Все не так вже й складно, дивимося код (приклад для телефонів):

public function rules()
{
    return [
        'phones.*.phone_number' => [
            'nullable',
            'distinct',
            'regex:/^\d{2}\s\(\d{3}\)\s\d{3}-\d{4}$/'
        ],
        'phones.*.type' => [
            'nullable',
            Rule::in(['mobile', 'home'])
        ],
        'phones.0.phone_number' => [
            'required',
            'regex:/^\d{2}\s\(\d{3}\)\s\d{3}-\d{4}$/'
        ],
        'phones.0.type' => [
            'required',
            Rule::in(['mobile', 'home'])
        ],
    ];
}

 

Тобто вказуємо, що всі поля phone_number можуть бути порожніми, але якщо вони заповнені, то повинні бути різними і відповідати певному формату, значення "тип" також може бути порожнім, і знову-таки, якщо не порожнє, то повинно бути рівним або mobile, або home. Після чого говоримо, що перший елемент масиву phones, а точніше вкладені значення phone_number та type - обов'язкові. Причому саме в такому порядку, інакше nullable перекриє required.

Потрібно розуміти, що якщо маємо справу з масивами, то при виведенні помилок замість :attribute буде підставлятися щось на зразок phones.0.type - не дуже то красиво. Ми знаємо, що є метод attributes(), який може допомогти. Але є ще один спосіб, і якщо сайт багатомовний, цей спосіб досить зручний. Маю на увазі про файли локалізації. Нам доведеться робити переклади, а значить в нас буде файл app/resources/lang/uk/validation.php, в кінці якого можна прописати масив з атрибутами:

'attributes' => [
    'phones.*.phone_number' => 'номер телефону',
    'phones.*.type' => 'тип'
],

 

Кастомні правила валідації

Ще одна цікава ситуація - створення кастомних правил. У Laravel багато правил, які покривають майже все, що завгодно. Ключове слово - майже. Якось треба було зробити наступну перевірку: поле X обов'язкове, якщо поле Y не є порожнім, тобто містить будь-яке значення. Здивувало те, що подібного правила "в коробці" не знайшлося - required_if, required_with працюють трохи інакше. Тому довелося трохи поламати голову над своїм required_if_not_empty:field.

У документації пропонуються три варіанти створення користувацьких правил - використання об'єктів Rule, використання замикань, і використання розширень. Перших два відпали, оскільки неможливо "дістати" значення поля Y (крім атрибута і значення поля Х, потрібні також параметри, в даному випадку параметр field). Я не став, як описано в доках, закидати розширення в AppServiceProvider, а створив окремого постачальника послуг - ValidationServiceProvider. Ось код:

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Validator;

class ValidationServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Validator::extend('required_if_not_empty', function($attribute, $value, $parameters, $validator) {
            $other = array_get($validator->getData(), $parameters[0], null);

            if (!empty($other)) {
                return $validator->validateRequired($attribute, $value);
            }

            return true;
        });
    }

    public function register()
    {
        //
    }
}

 

Тобто отримуємо всі дані валідатора, і за допомогою хелперу array_get() дістаємо значення першого параметра, яким є зазначений у правилі field. На той випадок, якщо такий параметр не знайдений, говоримо функції повернути null. І далі, якщо параметр (поле Y) не порожній, перевіряємо чи заповнене поле Х. Якщо ж Y не заповнений - повертаємо true - іншими словами поле Х пройшло перевірку.

Не забудемо додати нового провайдера в масив $providers у файлі config/app.php. Тепер можна використовувати правило де завгодно.

Висновок

Перевірка будь-яких даних в Laravel не проблема. Навіть якщо відсутнє необхідне правило, можна написати своє, вибравши найбільш відповідний для конкретного випадку підхід. Що стосується валідації в загальному - перевірка даних в контролерах можлива, але все ж FormRequest - рішення більш гнучке, що дозволяє зберігати код чистим і читабельним.