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):
prepareForValidation()- нормализация данныхauthorize()- проверка доступа (403, если false; без метода - авторизовано)rules()- получение набора правил- Валидация по правилам
after()- дополнительные проверки (выполняются всегда, даже при ошибках в п.4)passedValidation()- постобработка (только если п.4 и п.5 без ошибок)- Метод контроллера
Важный нюанс: 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() и запускать дорогую логику только если базовые правила прошли.