
FormRequest: валидация форм в Laravel
05.07.2018 17:33 | 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 - решение более гибкое, позволяющее сохранять код чистым и читабельным.