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. Дело в том, что если человек добрался до этой формы, то мы уже точно проверяли его каким-нибудь 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/ru/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 - решение более гибкое, позволяющее сохранять код чистым и читабельным.