Form Request в Laravel 13

Form Request - выделенный класс, который забирает валидацию и авторизацию из контроллера. Вместо десятков строк правил в методе store() контроллер получает один аргумент с типом вашего класса, а вся логика проверки живёт в своём файле. Результат: контроллеры тоньше, а правила валидации можно тестировать отдельно.

Для простых случаев (2-3 правила) хватает $request->validate() прямо в контроллере - об этом в основах валидации. Form Request оправдан, когда логика валидации растёт: появляются кастомные сообщения, препроцессинг данных, дополнительные проверки через after() или правила занимают больше десятка строк.

Создание Form Request

php artisan make:request StoreArticleRequest

Файл появится в app/Http/Requests/StoreArticleRequest.php. Имена принято строить по шаблону: действие + сущность + Request. StoreOrderRequest, UpdateProfileRequest, DestroyCommentRequest - по имени класса сразу ясно, какой эндпоинт он обслуживает.

Сгенерированный класс содержит два метода: authorize() и rules().

Для организации крупных проектов Form Request можно группировать по подпапкам:

php artisan make:request Admin/StoreUserRequest

Файл появится в app/Http/Requests/Admin/StoreUserRequest.php. Namespace обновится автоматически.

Метод rules()

Формат правил тот же, что и в $request->validate() - массив, где ключ это имя поля:

public function rules(): array
{
    return [
        'title'       => ['required', 'string', 'max:255'],
        'body'        => ['required', 'string', 'min:50'],
        'category_id' => ['required', 'exists:categories,id'],
        'tags'        => ['nullable', 'array', 'max:10'],
        'tags.*'      => ['string', 'max:30'],
    ];
}

Зависимости в rules() можно получить через service container. Документация Laravel подтверждает это для rules() и authorize():

use App\Services\PlanLimits;

public function rules(PlanLimits $limits): array
{
    return [
        'attachments'   => ['nullable', 'array', 'max:' . $limits->maxAttachments()],
        'attachments.*' => ['file', 'max:' . $limits->maxFileSizeKb()],
    ];
}

Авторизация: authorize()

Метод определяет, имеет ли текущий пользователь право выполнить запрос. Если вернёт false, контроллер не вызовется, а пользователь получит 403:

public function authorize(): bool
{
    return $this->user()->can('create', Article::class);
}

Для публичных форм (регистрация, обратная связь) верните true или уберите метод совсем - если authorize() отсутствует, запрос считается авторизованным.

Доступ к route model binding

Внутри Form Request доступны параметры роута через $this->route(). Если используется route model binding, получаете готовый экземпляр модели:

// Route: PUT /articles/{article}

public function authorize(): bool
{
    return $this->user()->can('update', $this->route('article'));
}

Ещё короче - обратиться к модели как к свойству запроса:

public function authorize(): bool
{
    return $this->user()->can('update', $this->article);
}

Это работает, потому что FormRequest наследует Request, который проксирует обращения к свойствам на параметры роута. При использовании route model binding $this->article вернёт экземпляр Article, а не ID.

authorize() и middleware

Если авторизация уже проверяется в middleware или policy, дублировать её в authorize() не нужно. Уберите метод - без него запрос авторизуется автоматически. Двойная проверка не опасна, но засоряет код и создаёт два места, которые нужно менять при изменении логики доступа.

Жизненный цикл Form Request

При получении запроса Laravel выполняет шаги в строгом порядке (см. ValidatesWhenResolvedTrait::validateResolved):

  1. prepareForValidation() - нормализация данных
  2. authorize() - проверка доступа (403, если false; без метода - авторизовано)
  3. rules() - получение набора правил
  4. Валидация по правилам
  5. after() - дополнительные проверки (выполняются всегда, даже при ошибках в п.4)
  6. passedValidation() - постобработка (только если п.4 и п.5 без ошибок)
  7. Метод контроллера

Важный нюанс: prepareForValidation() запускается до authorize(). Если в prepareForValidation() обращаться к $this->user(), метод сработает даже на запросах, которые позже получат 403 в authorize(). То же касается тяжёлой нормализации - её делает каждый запрос, в том числе неавторизованный.

Если правила не прошли, passedValidation() не вызывается, но after() выполнится - через него можно добавить ошибки поверх уже найденных.

В prepareForValidation() данные ещё не проверены, поэтому работайте с ними осторожно. В passedValidation() данные уже валидны, можно обращаться к $this->validated().

Подключение в контроллере

Укажите Form Request как тип аргумента - Laravel автоматически запустит валидацию до вызова метода:

public function store(StoreArticleRequest $request): RedirectResponse
{
    $article = Article::create($request->validated());

    return to_route('articles.show', $article);
}

Если валидация провалится, метод контроллера не выполнится. Для web-запроса пользователь получит редирект назад с ошибками в сессии. Для XHR/API - JSON с кодом 422.

validated() и safe()

validated() возвращает массив проверенных данных. safe() возвращает объект ValidatedInput с методами фильтрации:

public function store(StoreArticleRequest $request): RedirectResponse
{
    // Все проверенные данные
    $data = $request->validated();

    // Конкретное поле
    $title = $request->validated('title');

    // Выборка
    $only = $request->safe()->only(['title', 'body']);
    $except = $request->safe()->except(['tags']);

    // Добавить поле
    $merged = $request->safe()->merge(['author_id' => $request->user()->id]);

    Article::create($merged->toArray());

    return to_route('articles.index');
}

Не используйте $request->all() после валидации - в нём могут быть поля, которые вы не проверяли. Это защита от mass assignment: злоумышленник может добавить is_admin=1 в тело запроса, и без validated() это поле попадёт в create().

prepareForValidation()

Хук для нормализации данных до запуска правил. Типичные задачи: очистка телефона, генерация slug, приведение формата даты:

use Illuminate\Support\Str;

protected function prepareForValidation(): void
{
    $this->merge([
        'slug'  => Str::slug($this->title),
        'phone' => $this->filled('phone')
            ? preg_replace('/[^\d+]/', '', $this->phone)
            : null,
    ]);
}

merge() добавляет или перезаписывает поля в запросе. После этого rules() получает уже чистые данные. Важно: prepareForValidation() работает с сырым запросом, поэтому проверяйте данные аккуратно - они ещё не прошли валидацию и могут содержать что угодно.

Другой частый сценарий - булевы чекбоксы. HTML-форма не отправляет чекбокс, если он не отмечен. prepareForValidation() может задать значение по умолчанию:

protected function prepareForValidation(): void
{
    if (!$this->has('is_published')) {
        $this->merge(['is_published' => false]);
    }
}

passedValidation()

Обратный хук - срабатывает после успешной валидации, но до контроллера. Полезен для постобработки: конвертация дат, добавление вычисляемых полей:

protected function passedValidation(): void
{
    $this->merge([
        'starts_at' => Carbon::parse($this->starts_at)->utc()->format('Y-m-d H:i:s'),
    ]);
}

$this->replace() затирает весь запрос целиком. В документации есть пример именно с replace(), но на практике поля вне validated-данных (_token, _method) будут потеряны. Если нужно сохранить остальные поля, используйте merge().

Учитывайте: поля, добавленные через merge() в passedValidation(), не попадут в $request->validated() - только поля из rules(). Если контроллер использует validated(), добавляйте вычисляемые поля в контроллере через safe()->merge():

public function store(StoreArticleRequest $request): RedirectResponse
{
    $data = $request->safe()->merge([
        'author_id' => $request->user()->id,
    ])->toArray();

    Article::create($data);

    return to_route('articles.index');
}

Дополнительная валидация: after()

Метод after() возвращает массив замыканий или invokable-классов, которые выполняются после основных правил. Нужен для проверок, которые невозможно выразить стандартными правилами:

public function after(): array
{
    return [
        function (\Illuminate\Validation\Validator $validator) {
            if ($this->hasOverlappingBooking()) {
                $validator->errors()->add('date', 'Бронь пересекается с существующей.');
            }
        },
    ];
}

Для сложной бизнес-логики вынесите проверку в invokable-класс:

public function after(): array
{
    return [
        new ValidateInventoryAvailability,
        new ValidateShippingRegion,
    ];
}

Класс реализует метод __invoke(\Illuminate\Validation\Validator $validator). Такие классы легко тестировать изолированно и переиспользовать между разными Form Request.

Пример invokable-класса:

class ValidateInventoryAvailability
{
    public function __invoke(\Illuminate\Validation\Validator $validator): void
    {
        $data = $validator->getData();
        $productId = $data['product_id'] ?? null;
        $quantity = (int) ($data['quantity'] ?? 0);

        if (null === $productId) {
            return;
        }

        $stock = Product::find($productId)?->stock ?? 0;

        if ($stock < $quantity) {
            $validator->errors()->add('quantity', 'Недостаточно товара на складе.');
        }
    }
}

$validator->getData() возвращает проверяемые данные. Через него invokable-класс получает доступ к полям без привязки к конкретному Form Request.

Доступ к данным запроса в правилах

Внутри Form Request доступны все методы Request: $this->input(), $this->query(), $this->has(), $this->filled(), $this->user(), $this->ip(), $this->route().

Типичная ситуация - правила зависят от текущего пользователя:

public function rules(): array
{
    $maxSize = $this->user()->isPremium() ? 10240 : 2048;

    return [
        'avatar' => ['nullable', 'image', "max:{$maxSize}"],
    ];
}

Или от параметра роута:

// Route: PUT /users/{user}/role

public function rules(): array
{
    return [
        'role' => [
            'required',
            Rule::in($this->allowedRoles()),
        ],
    ];
}

private function allowedRoles(): array
{
    $target = $this->route('user');

    // Нельзя назначить роль выше своей
    if ($this->user()->isAdmin()) {
        return ['admin', 'editor', 'viewer'];
    }

    return ['editor', 'viewer'];
}

Здесь набор допустимых ролей зависит от прав текущего пользователя. Это невозможно выразить через стандартные правила - нужна PHP-логика.

Сообщения и имена полей

messages()

Стандартные сообщения Laravel на английском и общие. Для конкретного Form Request их можно заменить:

public function messages(): array
{
    return [
        'title.required'    => 'Укажите заголовок.',
        'title.max'         => 'Заголовок слишком длинный (максимум :max символов).',
        'category_id.exists' => 'Выбранная категория не существует.',
    ];
}

attributes()

Без этого метода пользователь видит “The category_id field is required”. С ним - “The категория field is required”:

public function attributes(): array
{
    return [
        'category_id' => 'категория',
        'body'        => 'текст статьи',
        'tags'        => 'метки',
    ];
}

Подробнее о локализации и форматировании ошибок - в статье про сообщения об ошибках.

PHP-атрибуты

Laravel использует PHP 8 атрибуты для настройки поведения Form Request.

StopOnFirstFailure

Останавливает валидацию всех полей при первой ошибке:

use Illuminate\Foundation\Http\Attributes\StopOnFirstFailure;

#[StopOnFirstFailure]
class ImportRequest extends FormRequest
{
    // ...
}

Без атрибута bail в правилах останавливает проверку одного поля, а StopOnFirstFailure - всего набора. Полезно при импорте, где первая же ошибка означает невалидную строку целиком.

RedirectTo и RedirectToRoute

По умолчанию при ошибке пользователь возвращается на предыдущую страницу. Атрибуты позволяют направить его в конкретное место:

use Illuminate\Foundation\Http\Attributes\RedirectTo;

#[RedirectTo('/settings')]
class UpdateSettingsRequest extends FormRequest
{
    // ...
}

Или по имени роута:

use Illuminate\Foundation\Http\Attributes\RedirectToRoute;

#[RedirectToRoute('settings.edit')]
class UpdateSettingsRequest extends FormRequest
{
    // ...
}

ErrorBag

Если на странице несколько форм, ошибки можно разнести по именованным наборам:

use Illuminate\Foundation\Http\Attributes\ErrorBag;

#[ErrorBag('login')]
class LoginRequest extends FormRequest
{
    // ...
}

В Blade ошибки этого набора доступны через $errors->login->first('email'). Без именованного набора ошибки нескольких форм на одной странице перемешиваются, и пользователь видит ошибки логина под формой регистрации.

FailOnUnknownFields

По умолчанию Form Request молча игнорирует поля, не описанные в rules() - они не попадают в validated(), но и ошибкой не считаются. Атрибут переворачивает поведение:

use Illuminate\Foundation\Http\Attributes\FailOnUnknownFields;

#[FailOnUnknownFields]
class UpdateProfileRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name'  => ['required', 'string', 'max:80'],
            'email' => ['required', 'email'],
        ];
    }
}

Запрос с полем is_admin, которого нет в правилах, падает с ошибкой validation.prohibited на это имя. Допустимыми считаются *_confirmation пары к описанным полям (password_confirmation при правиле password) и элементы массивов, покрытые wildcard-правилом - items.*.qty пропускает items.0.qty, items.1.qty.

Включить проверку для всех Form Request разом - вызовом в AppServiceProvider::boot():

use Illuminate\Foundation\Http\FormRequest;

public function boot(): void
{
    FormRequest::failOnUnknownFields();
}

Полезно в API: клиент сразу видит, что отправил лишнее поле, и не тратит время на отладку молча отброшенных данных. Для обычных HTML-форм глобальный режим обычно избыточен - на серверной стороне проще менять контракт, не подстраивая фронтенд под каждое скрытое поле.

Условные правила в rules()

Правила в rules() могут зависеть от входных данных. Доступ к полям запроса - через $this->input() или $this->имя_поля:

public function rules(): array
{
    $rules = [
        'type' => ['required', 'in:individual,company'],
        'name' => ['required', 'string', 'max:100'],
    ];

    if ('company' === $this->input('type')) {
        $rules['company_name'] = ['required', 'string', 'max:200'];
        $rules['tax_id'] = ['required', 'string', 'size:12'];
    }

    return $rules;
}

Альтернатива - декларативные правила required_if, required_with:

public function rules(): array
{
    return [
        'type'         => ['required', 'in:individual,company'],
        'name'         => ['required', 'string', 'max:100'],
        'company_name' => ['required_if:type,company', 'nullable', 'string', 'max:200'],
        'tax_id'       => ['required_if:type,company', 'nullable', 'string', 'size:12'],
    ];
}

Первый подход (if в PHP) гибче: можно строить правила на основе запросов к базе, текущего пользователя, чего угодно. Второй лаконичнее для простых зависимостей между полями. Подробнее - в статье про условную валидацию.

Form Request для API

Для API Form Request работает так же, но ответ при ошибке - JSON, а не редирект. Laravel определяет формат автоматически по заголовку Accept:

class StoreOrderRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->tokenCan('orders:create');
    }

    public function rules(): array
    {
        return [
            'product_id' => ['required', 'exists:products,id'],
            'quantity'   => ['required', 'integer', 'min:1', 'max:100'],
        ];
    }
}

При ошибке клиент получит 422:

{
    "message": "The product id field is required.",
    "errors": {
        "product_id": ["The product id field is required."]
    }
}

Для Sanctum/Passport authorize() может проверять abilities токена через tokenCan(). Если пользователь не авторизован (нет токена), middleware auth:sanctum вернёт 401 до Form Request.

failedValidation и failedAuthorization

В редких случаях нужно переопределить поведение при ошибке. Базовый FormRequest выбрасывает ValidationException при провале правил и AuthorizationException при провале authorize. Если нужен нестандартный ответ:

use Illuminate\Contracts\Validation\Validator;

protected function failedValidation(Validator $validator): void
{
    // Логирование перед стандартным поведением
    Log::channel('validation')->info('Validation failed', [
        'url'    => $this->url(),
        'errors' => $validator->errors()->toArray(),
    ]);

    parent::failedValidation($validator);
}

Переопределение failedValidation полезно для мониторинга: логируете ошибки, а затем вызываете parent для стандартного поведения. Полная замена поведения - редкий случай, обычно хватает стандартного.

Form Request для store и update

CRUD-контроллер обычно нуждается в двух Form Request: один для создания, другой для обновления. Правила почти совпадают, но есть отличия (например, unique с ignore при обновлении).

Первый подход - общий трейт с базовыми правилами:

trait ArticleRules
{
    protected function baseRules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'body'  => ['required', 'string', 'min:50'],
            'tags'  => ['nullable', 'array'],
        ];
    }
}
class StoreArticleRequest extends FormRequest
{
    use ArticleRules;

    public function rules(): array
    {
        return array_merge($this->baseRules(), [
            'slug' => ['required', 'string', 'unique:articles'],
        ]);
    }
}
class UpdateArticleRequest extends FormRequest
{
    use ArticleRules;

    public function rules(): array
    {
        return array_merge($this->baseRules(), [
            'slug' => ['required', 'string', Rule::unique('articles')->ignore($this->article)],
        ]);
    }
}

Второй подход - один Form Request с условием по методу запроса:

public function rules(): array
{
    $rules = [
        'title' => ['required', 'string', 'max:255'],
        'body'  => ['required', 'string'],
    ];

    if ('POST' === $this->method()) {
        $rules['slug'] = ['required', 'unique:articles'];
    } else {
        $rules['slug'] = ['required', Rule::unique('articles')->ignore($this->article)];
    }

    return $rules;
}

Трейт с baseRules() чище: каждый класс отвечает за один сценарий. Один Request с if по методу компактнее, но хуже масштабируется при росте отличий.

При обновлении часто нужно сделать поля необязательными (PATCH с частичным обновлением). Одно решение - sometimes:

// UpdateArticleRequest

public function rules(): array
{
    return [
        'title' => ['sometimes', 'required', 'string', 'max:255'],
        'body'  => ['sometimes', 'required', 'string'],
        'slug'  => ['sometimes', 'required', Rule::unique('articles')->ignore($this->article)],
    ];
}

sometimes означает: применять правила, только если поле присутствует в запросе. Если клиент отправил только title, правила для body и slug не выполнятся.

Валидация route-параметров

Form Request валидирует тело запроса, но иногда нужно проверить и параметры URL. Метод validationData() позволяет включить их:

public function validationData(): array
{
    return array_merge($this->all(), [
        'user_id' => $this->route('user'),
    ]);
}

public function rules(): array
{
    return [
        'user_id' => ['required', 'exists:users,id'],
        'role'    => ['required', 'in:admin,editor,viewer'],
    ];
}

Теперь user_id из URL проходит те же правила, что и поля из тела запроса. Обратите внимание: если route model binding активен, $this->route('user') вернёт экземпляр модели, а не ID. В таком случае передавайте $this->route('user')->id.

Form Request для GET-запросов

Form Request работает не только с POST/PUT. Валидация GET-запроса нужна для фильтров, поиска и пагинации - query-параметры тоже бывают невалидными:

class ProductFilterRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'search'   => ['nullable', 'string', 'max:100'],
            'category' => ['nullable', 'exists:categories,id'],
            'sort'     => ['nullable', 'in:price,name,created_at'],
            'order'    => ['nullable', 'in:asc,desc'],
            'per_page' => ['nullable', 'integer', 'min:10', 'max:100'],
        ];
    }
}
public function index(ProductFilterRequest $request): View
{
    $products = Product::query()
        ->when($request->validated('search'), fn ($q, $s) => $q->where('name', 'like', "%{$s}%"))
        ->when($request->validated('category'), fn ($q, $c) => $q->where('category_id', $c))
        ->orderBy(
            $request->validated('sort', 'created_at'),
            $request->validated('order', 'desc')
        )
        ->paginate($request->validated('per_page', 20));

    return view('products.index', compact('products'));
}

В production-коде экранируйте спецсимволы % и _ в LIKE через addcslashes($s, '%_') или метод whereLike().

Query-параметры из URL автоматически попадают в данные запроса - отдельной обработки не требуется.

Валидация без Form Request

Form Request привязан к HTTP. Для данных из других источников (очередь, CSV, внешний API) используйте Validator::make():

use Illuminate\Support\Facades\Validator;

$validator = Validator::make($data, [
    'email' => ['required', 'email'],
    'name'  => ['required', 'string'],
]);

if ($validator->fails()) {
    // обработка ошибок
}

$clean = $validator->validated();

Для валидации данных из конфига, очереди или CSV вся логика укладывается в несколько строк без церемоний Form Request.

Ещё один случай - когда валидация в контроллере проще. Для поискового фильтра с двумя параметрами создавать отдельный класс - излишество:

public function index(Request $request): View
{
    $validated = $request->validate([
        'search' => ['nullable', 'string', 'max:100'],
        'sort'   => ['nullable', 'in:name,date,price'],
    ]);

    return view('products.index', [
        'products' => Product::filter($validated)->paginate(),
    ]);
}

Подробнее о Validator::make() и выборе подхода - в основах валидации.

Тестирование Form Request

Form Request можно тестировать через HTTP-тест или напрямую.

HTTP-тест проверяет весь стек:

public function test_store_requires_title(): void
{
    $this->actingAs(User::factory()->create());

    $response = $this->post('/articles', [
        'body' => 'Content without title',
    ]);

    $response->assertSessionHasErrors('title');
}

public function test_update_allows_own_slug(): void
{
    $user = User::factory()->create();
    $article = Article::factory()->for($user)->create(['slug' => 'my-article']);
    $this->actingAs($user);

    $response = $this->put("/articles/{$article->id}", [
        'title' => 'Updated',
        'body'  => 'Updated body content here',
        'slug'  => 'my-article',
    ]);

    $response->assertSessionHasNoErrors();
}

Для проверки конкретного сообщения:

$response->assertSessionHasErrors([
    'title' => 'Укажите заголовок.',
]);

Для API-тестов:

public function test_api_validation_returns_422(): void
{
    Sanctum::actingAs(User::factory()->create());

    $response = $this->postJson('/api/orders', []);

    $response->assertStatus(422)
             ->assertJsonValidationErrors(['product_id', 'quantity']);
}

Для проверки authorize() отдельно:

public function test_unauthorized_user_gets_403(): void
{
    $user = User::factory()->create();
    $this->actingAs($user);

    $response = $this->post('/admin/settings', [
        'site_name' => 'Test',
    ]);

    $response->assertForbidden();
}

Наследование Form Request

Для группы похожих Form Request можно создать базовый класс:

abstract class BaseArticleRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function messages(): array
    {
        return [
            'title.required' => 'Укажите заголовок.',
            'body.required'  => 'Текст статьи обязателен.',
        ];
    }

    public function attributes(): array
    {
        return [
            'category_id' => 'категория',
        ];
    }
}

Дочерние классы наследуют messages(), attributes() и authorize(), но определяют свои rules():

class StoreArticleRequest extends BaseArticleRequest
{
    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'body'  => ['required', 'string'],
            'slug'  => ['required', 'unique:articles'],
        ];
    }
}

Подход чище трейтов, когда Form Request разделяют не только правила, но и сообщения, атрибуты, авторизацию. Трейт подходит, когда общее - только набор базовых правил.

Не злоупотребляйте наследованием: три уровня глубины - уже признак переусложнения. Обычно хватает одного базового класса и нескольких конкретных.

Полный пример: CRUD статей

Роуты:

Route::resource('articles', ArticleController::class);

StoreArticleRequest:

class StoreArticleRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Article::class);
    }

    public function rules(): array
    {
        return [
            'title'       => ['required', 'string', 'max:255'],
            'slug'        => ['required', 'string', 'max:255', 'unique:articles'],
            'body'        => ['required', 'string', 'min:50'],
            'category_id' => ['required', 'exists:categories,id'],
            'is_published' => ['boolean'],
        ];
    }

    protected function prepareForValidation(): void
    {
        $this->merge([
            'slug' => Str::slug($this->slug ?: $this->title),
        ]);
    }
}

Контроллер:

class ArticleController extends Controller
{
    public function store(StoreArticleRequest $request): RedirectResponse
    {
        $article = $request->user()->articles()->create($request->validated());

        return to_route('articles.show', $article);
    }

    public function update(UpdateArticleRequest $request, Article $article): RedirectResponse
    {
        $article->update($request->validated());

        return to_route('articles.show', $article);
    }
}

Частые ошибки

authorize() возвращает false

Если в authorize() стоит return false (например, оставили заглушку при разработке), все запросы получают 403 без информативного сообщения. Если авторизация проверяется в другом месте, уберите метод совсем - без него FormRequest авторизует запрос автоматически.

$request->all() после валидации

// Злоумышленник добавил is_admin=1 в запрос
Article::create($request->all());      // is_admin попадёт в модель
Article::create($request->validated()); // только поля из rules()

prepareForValidation перезаписывает поля

// Затирает поле, даже если оно не передано
$this->merge(['phone' => preg_replace('/[^\d+]/', '', $this->phone)]);

Если phone не передан, $this->phone вернёт null, а preg_replace с null вызовет deprecation warning в PHP 8.1+. Проверяйте наличие поля:

if ($this->filled('phone')) {
    $this->merge(['phone' => preg_replace('/[^\d+]/', '', $this->phone)]);
}

Один Form Request на весь CRUD

Соблазн создать ArticleRequest и использовать его для store, update и destroy. На практике правила для создания и обновления отличаются (unique, иногда required vs nullable), а destroy вообще не нуждается в валидации тела. Отдельные классы чище.

DI в authorize() не отрабатывает

Тип в сигнатуре authorize() не зарегистрирован в контейнере? Вместо 403 получите BindingResolutionException. Проверьте, что зависимость зарегистрирована.

Правила не применяются к route-параметрам

По умолчанию rules() валидирует только тело запроса и query-параметры. Параметры из URL (/articles/{id}) не попадают в валидацию. Для их проверки переопределите validationData() и включите нужные параметры.

Забыли указать type-hint в контроллере

// Валидация НЕ запустится - тип Request, а не StoreArticleRequest
public function store(Request $request) { ... }

// Валидация запустится
public function store(StoreArticleRequest $request) { ... }

Код скомпилируется без ошибок, запросы будут приниматься без проверки. Опасная ошибка, которую легко пропустить.

messages() не переопределяет lang-файлы

Метод messages() в Form Request имеет приоритет только для правил этого конкретного запроса. Глобальные переводы в lang/ru/validation.php продолжают действовать для остальных. Если нужно изменить сообщения глобально - правьте lang-файлы. messages() в Form Request - для точечных переопределений.

Тяжёлая логика в rules()

rules() вызывается на каждый запрос. Если внутри есть запросы к базе или внешним сервисам, это замедлит отклик. Тяжёлую проверку лучше вынести в after() callback, где внутри можно проверить $validator->errors()->isEmpty() и запускать дорогую логику только если базовые правила прошли.