Scattered validation logic in controllers is how projects get unmaintainable. You end up with the same field rules copied across three different endpoints, inconsistent error message formats, and no good way to share validation between web and API routes. When a business rule changes — say, friend requests now require a 7-day account age — you hunt down every place you checked it.
Laravel Form Requests solve this by giving validation a dedicated home. Combined with Inertia.js, they also make error display on the Vue side almost automatic — the framework handles the 422 response and populates form.errors without any custom handling code.
This tutorial walks through the validation system from MyKpopLists: friend request validation with business logic, privacy settings with dynamic rule checking, and polymorphic activity tracking.
Before You Start
- PHP 8.2+ with class types and match expressions
- Laravel 12 — familiarity with controllers, models, and routing
- Inertia.js — basic understanding of how it bridges Laravel and Vue
- Vue 3 Composition API
php artisan make:request SendFriendRequestRequest
Three Gates: Authorization, Rules, Business Logic
sequenceDiagram
participant Vue as Vue Component
participant Inertia as Inertia.js
participant Route as Laravel Route
participant FormRequest as Form Request
participant Controller as Controller
participant Model as Eloquent Model
Vue->>Inertia: Submit form data
Inertia->>Route: POST /friends/send
Route->>FormRequest: Type-hint in controller
FormRequest->>FormRequest: authorize() check
alt Not Authorized
FormRequest-->>Inertia: 403 Forbidden
Inertia-->>Vue: Show error
end
FormRequest->>FormRequest: rules() validation
alt Validation Fails
FormRequest-->>Inertia: 422 with errors
Inertia-->>Vue: Reactive error display
end
FormRequest->>FormRequest: withValidator() custom logic
alt Custom Validation Fails
FormRequest-->>Inertia: 422 with errors
Inertia-->>Vue: Reactive error display
end
FormRequest->>Controller: Pass validated data
Controller->>Model: Create/Update
Model-->>Controller: Success
Controller-->>Inertia: Redirect with success
Inertia-->>Vue: Update UI
Every Form Request runs three stages before the controller method executes. authorize() answers “can this user even attempt this action?” — returning false throws a 403. rules() validates data shape and format, returning a 422 on failure. withValidator() lets you add business logic that runs after basic validation passes. The controller only sees data that survived all three.
The Basic Structure and Business Logic Gate
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class SendFriendRequestRequest extends FormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return []; // Route model binding validates the user parameter
}
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$targetUser = $this->route('user');
$currentUser = $this->user();
// Rule 1: Can't friend yourself
if ($targetUser->id === $currentUser->id) {
$validator->errors()->add('user', 'You cannot send a friend request to yourself.');
}
// Rule 2: Check for existing friendship
$existingFriendship = $currentUser->friendships()
->where(function ($query) use ($targetUser) {
$query->where('requester_id', $targetUser->id)
->orWhere('addressee_id', $targetUser->id);
})
->first();
if ($existingFriendship) {
$message = match($existingFriendship->status) {
'accepted' => 'You are already friends with this user.',
'pending' => 'A friend request is already pending.',
'blocked' => 'Unable to send friend request.',
default => 'A friendship already exists.',
};
$validator->errors()->add('user', $message);
}
// Rule 3: Check privacy settings
$privacyService = app(\App\Services\PrivacyService::class);
if (!$privacyService->canSendFriendRequest($currentUser, $targetUser)) {
$validator->errors()->add('user', 'This user is not accepting friend requests.');
}
});
}
}
$this->route('user') accesses the route-model-bound parameter. $this->user() gets the authenticated user. Both are available inside withValidator() without any additional setup.
Keep authorize() and withValidator() concerns separated: authorize() answers who can do this, withValidator() answers whether the specific data makes sense in context. Don’t put “can’t friend yourself” logic in authorize() — that’s validation logic masquerading as authorization.
Dynamic Array Validation for Privacy Settings
class UpdatePrivacySettingsRequest extends FormRequest
{
public function authorize(): bool
{
$user = $this->route('user');
return auth()->check() && auth()->id() === $user->id;
}
public function rules(): array
{
return [
'settings' => ['required', 'array', 'min:1'],
'settings.*' => ['boolean'],
];
}
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$settings = $this->input('settings', []);
$validSettings = [
'show_comments_tab', 'show_activity_tab', 'show_friends_list',
'show_following_list', 'allow_friend_requests', 'show_online_status',
];
foreach (array_keys($settings) as $key) {
if (!in_array($key, $validSettings)) {
$validator->errors()->add("settings.{$key}", 'Invalid privacy setting.');
}
}
});
}
public function messages(): array
{
return [
'settings.required' => 'Privacy settings are required.',
'settings.array' => 'Privacy settings must be an array.',
'settings.min' => 'At least one privacy setting must be provided.',
'settings.*.boolean' => 'Privacy setting values must be true or false.',
];
}
}
settings.* validates all values in the settings array. Nested error keys like settings.{$key} create field-specific errors that Inertia maps back to the exact form field that failed.
Polymorphic Validation for Activity Tracking
The most complex example — validating polymorphic relationships where the required metadata changes based on the activity type:
class TrackActivityRequest extends FormRequest
{
public function authorize(): bool { return auth()->check(); }
public function rules(): array
{
return [
'activity_type' => ['required', 'string', Rule::in(UserActivity::getActivityTypes())],
'subject_type' => ['required', 'string', Rule::in($this->getValidSubjectTypes())],
'subject_id' => ['required', 'integer', 'min:1'],
'metadata' => ['sometimes', 'array', 'max:10'],
'metadata.*' => ['string', 'max:255'],
];
}
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$subjectType = $this->input('subject_type');
$subjectId = $this->input('subject_id');
$modelClass = $this->getModelClassFromType($subjectType);
if ($modelClass && !$modelClass::find($subjectId)) {
$validator->errors()->add('subject_id', 'The specified subject does not exist.');
}
$this->validateMetadata($validator);
});
}
protected function getModelClassFromType(string $type): ?string
{
$modelMap = [
'post' => \App\Models\Post::class, 'review' => \App\Models\Review::class,
'comment' => \App\Models\Comment::class, 'user_list' => \App\Models\UserList::class,
'idol' => \App\Models\Idol::class, 'song' => \App\Models\Song::class,
];
return $modelMap[$type] ?? null;
}
protected function validateMetadata($validator): void
{
$activityType = $this->input('activity_type');
$metadata = $this->input('metadata', []);
$requiredMetadata = match($activityType) {
UserActivity::TYPE_REVIEW => ['rating'],
UserActivity::TYPE_COMMENT => ['content'],
default => [],
};
foreach ($requiredMetadata as $required) {
if (!isset($metadata[$required])) {
$validator->errors()->add(
"metadata.{$required}",
"The {$required} field is required for this activity type."
);
}
}
}
}
Clean Controllers and Vue Error Handling
The controller becomes almost trivially simple:
public function sendRequest(SendFriendRequestRequest $request, User $user)
{
$result = $this->friendService->sendFriendRequest($request->user(), $user);
return redirect()->back()->with('success', $result['message']);
}
Type-hint the Form Request instead of the base Request class — that’s the only change required. Laravel resolves it, runs all three validation stages, and only calls your method if everything passes.
On the Vue side, Inertia makes error display automatic:
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3'
const form = useForm({
settings: { show_comments_tab: true, allow_friend_requests: true }
})
const submit = () => {
form.post(route('profile.privacy.update', { user: props.user.id }), {
preserveScroll: true,
})
}
</script>
<template>
<form @submit.prevent="submit">
<div v-for="(value, key) in form.settings" :key="key">
<label>
<input type="checkbox" v-model="form.settings[key]" :disabled="form.processing" />
{{ formatLabel(key) }}
</label>
<p v-if="form.errors[`settings.${key}`]" class="error">
{{ form.errors[`settings.${key}`] }}
</p>
</div>
<button type="submit" :disabled="form.processing">Save Settings</button>
</form>
</template>
useForm() provides reactive form state and error handling. form.errors is automatically populated from Laravel’s validation response. form.processing tracks submission state. preserveScroll keeps the user’s scroll position on validation failures — genuinely useful when the error is at the bottom of a long form.
Watch Out For
N+1 queries in validation. If you have an array of user IDs to validate, don’t query each one individually inside withValidator(). Fetch them all in a single whereIn query and diff against the input array.
Forgetting to type-hint. If you use the base Request class instead of your Form Request, validation never runs. The method receives whatever raw data was submitted.
File uploads work the same way. The hasFile() and file() methods are available inside withValidator(), and you can add custom dimension or format checks there after the basic mimes and max rules have already run.
Conditional rules via sometimes are worth knowing: password validation only when creating (not updating), email uniqueness ignoring the current user’s own record on updates. The Rule::unique()->ignore($id) pattern handles the update case cleanly.