On this page
- Purpose
- The Problem
- The Solution
- Prerequisites & Tooling
- Knowledge Base
- Environment Setup
- High-Level Architecture
- The Request Lifecycle
- Three Layers of Defense
- The Implementation
- Basic Form Request Structure
- Adding Custom Validation Logic
- Complex Validation with Dynamic Rules
- Polymorphic Validation
- Using Form Requests in Controllers
- Handling Errors in Vue Components
- Under the Hood
- How Laravel Processes Form Requests
- Memory and Performance Considerations
- How Inertia Handles Validation Errors
- Edge Cases & Pitfalls
- Authorization vs. Validation
- N+1 Queries in Validation
- Forgetting to Type-Hint
- Handling File Uploads
- Conditional Validation Rules
- Conclusion
- What You’ve Learned
- The Production Pattern
- Real-World Impact
- Next Steps
Purpose
The Problem
You’re building forms for user input: registration, profile updates, post creation, friend requests. Each form needs:
- Server-side validation (never trust the client)
- Clear error messages for each field
- Real-time feedback without page refresh
- Consistent validation rules across the application
- Protection against common attacks (XSS, SQL injection)
The naive approach: Validate in the controller with scattered logic.
// ❌ NAIVE APPROACH - Validation in controller
public function store(Request $request)
{
if (empty($request->title)) {
return back()->withErrors(['title' => 'Title is required']);
}
if (strlen($request->title) > 255) {
return back()->withErrors(['title' => 'Title too long']);
}
if (empty($request->content)) {
return back()->withErrors(['content' => 'Content is required']);
}
// ... 20 more validation checks
Post::create($request->all());
}
Problems:
- Code duplication: Same validation repeated across multiple controllers
- Hard to maintain: Changing validation rules requires updating multiple files
- No reusability: Can’t share validation logic between API and web routes
- Messy controllers: Business logic mixed with validation
- Inconsistent errors: Different error formats across the application
The Solution
Laravel Form Requests provide a dedicated class for validation logic, keeping controllers clean and validation rules reusable. Combined with Inertia.js, errors automatically flow to your Vue components without manual handling.
What we’re building: A production-ready form validation system from MyKpopLists that handles:
- Friend request validation with business logic (can’t friend yourself, check existing friendships)
- Privacy settings validation with dynamic rule checking
- Activity tracking with polymorphic validation
- Real-time error display in Vue components
This tutorial demonstrates separation of concerns, authorization logic, custom validation rules, and seamless frontend-backend integration—skills that distinguish senior developers.
Prerequisites & Tooling
Knowledge Base
- PHP 8.2+: Understanding of classes, type hints, and array functions
- Laravel 12: Familiarity with controllers, models, and routing
- Inertia.js: Basic understanding of how Inertia bridges Laravel and Vue
- Vue 3 Composition API: Knowledge of reactive state and form handling
Environment Setup
# Laravel with Inertia already installed
composer require laravel/framework "^12.0"
composer require inertiajs/inertia-laravel "^2.0"
# Generate a Form Request class
php artisan make:request SendFriendRequestRequest
High-Level Architecture
The Request Lifecycle
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
Three Layers of Defense
Think of Form Requests as a security checkpoint with three gates:
-
Gate 1 - Authorization (
authorize()): “Are you allowed to even attempt this?”- Like a bouncer checking your ID before you enter
-
Gate 2 - Basic Validation (
rules()): “Is your data in the right format?”- Like a form checker ensuring all required fields are filled
-
Gate 3 - Business Logic (
withValidator()): “Does this make sense in context?”- Like a manager verifying the request doesn’t violate business rules
Only after passing all three gates does your data reach the controller.
The Implementation
Basic Form Request Structure
Let’s start with the simplest Form Request and build up complexity.
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class SendFriendRequestRequest extends FormRequest
{
/**
* Gate 1: Authorization
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
// Only authenticated users can send friend requests
return auth()->check();
}
/**
* Gate 2: Basic Validation Rules
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
// The user parameter is validated by route model binding
// No additional rules needed here
];
}
}
What’s happening:
authorize()runs first—if it returnsfalse, Laravel throws a 403 Forbiddenrules()defines validation constraints using Laravel’s validation syntax- If validation fails, Laravel automatically returns a 422 response with error details
Adding Custom Validation Logic
Now let’s add business logic validation using withValidator():
use App\Models\User;
use Illuminate\Validation\Rule;
class SendFriendRequestRequest extends FormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
// Route model binding handles user validation
];
}
/**
* Gate 3: Business Logic Validation
* Configure the validator instance with custom logic.
*/
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$targetUser = $this->route('user'); // Get user from route
$currentUser = $this->user(); // Get authenticated user
// Rule 1: Cannot send friend request to yourself
if ($targetUser->id === $currentUser->id) {
$validator->errors()->add(
'user',
'You cannot send a friend request to yourself.'
);
}
// Rule 2: Check if friendship already exists
$existingFriendship = $currentUser->friendships()
->where(function ($query) use ($targetUser) {
$query->where('requester_id', $targetUser->id)
->orWhere('addressee_id', $targetUser->id);
})
->first();
if ($existingFriendship) {
$status = $existingFriendship->status;
$message = match($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.'
);
}
});
}
}
Key Techniques:
$this->route('user')accesses route parameters (from route model binding)$this->user()gets the authenticated user$validator->errors()->add()manually adds validation errorsmatch()expression provides clean conditional messages- Dependency injection via
app()helper for services
Complex Validation with Dynamic Rules
Let’s examine a more complex example with array validation:
class UpdatePrivacySettingsRequest extends FormRequest
{
public function authorize(): bool
{
$user = $this->route('user');
// Only allow users to update their own settings
return auth()->check() && auth()->id() === $user->id;
}
public function rules(): array
{
return [
'settings' => [
'required',
'array',
'min:1', // At least one setting must be provided
],
'settings.*' => [
'boolean', // All values must be true/false
],
];
}
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$settings = $this->input('settings', []);
// Define allowed setting keys
$validSettings = [
'show_comments_tab',
'show_activity_tab',
'show_friends_list',
'show_following_list',
'allow_friend_requests',
'show_online_status',
];
// Validate each key exists in allowed list
foreach (array_keys($settings) as $key) {
if (!in_array($key, $validSettings)) {
$validator->errors()->add(
"settings.{$key}",
'Invalid privacy setting.'
);
}
}
});
}
/**
* Custom error messages for better UX
*/
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.',
];
}
}
Advanced Patterns:
- Wildcard validation:
settings.*validates all array values - Nested error keys:
settings.{$key}creates field-specific errors - Custom messages:
messages()method overrides default error text - Authorization with route parameters: Check ownership before validation
Polymorphic Validation
The most complex example—validating polymorphic relationships:
class TrackActivityRequest extends FormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'activity_type' => [
'required',
'string',
Rule::in(UserActivity::getActivityTypes()), // Enum validation
],
'subject_type' => [
'required',
'string',
Rule::in($this->getValidSubjectTypes()),
],
'subject_id' => [
'required',
'integer',
'min:1',
],
'metadata' => [
'sometimes', // Optional field
'array',
'max:10', // Limit array size
],
'metadata.*' => [
'string',
'max:255',
],
];
}
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$subjectType = $this->input('subject_type');
$subjectId = $this->input('subject_id');
// Validate that the polymorphic subject exists
$modelClass = $this->getModelClassFromType($subjectType);
if ($modelClass && !$modelClass::find($subjectId)) {
$validator->errors()->add(
'subject_id',
'The specified subject does not exist.'
);
}
// Validate metadata based on activity type
$this->validateMetadata($validator);
});
}
/**
* Map string types to model classes
*/
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,
'group' => \App\Models\Group::class,
'idol' => \App\Models\Idol::class,
'song' => \App\Models\Song::class,
'album' => \App\Models\Album::class,
'user' => \App\Models\User::class,
];
return $modelMap[$type] ?? null;
}
/**
* Context-aware metadata validation
*/
protected function validateMetadata($validator): void
{
$activityType = $this->input('activity_type');
$metadata = $this->input('metadata', []);
// Different activity types require different 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."
);
}
}
}
}
Production Patterns:
- Enum validation:
Rule::in()ensures values match predefined constants - Polymorphic existence check: Verify the related model exists in database
- Context-aware validation: Rules change based on other field values
- Helper methods: Extract complex logic into private methods for readability
Using Form Requests in Controllers
The controller becomes incredibly clean:
class FriendController extends Controller
{
/**
* Send a friend request.
*
* The SendFriendRequestRequest automatically:
* 1. Checks authorization
* 2. Validates all rules
* 3. Runs custom business logic
*
* If any step fails, Laravel handles the error response.
* We only receive valid, authorized data here.
*/
public function sendRequest(SendFriendRequestRequest $request, User $user)
{
$currentUser = Auth::user();
try {
$result = $this->friendService->sendFriendRequest($currentUser, $user);
return redirect()->back()->with('success', $result['message']);
} catch (\Exception $e) {
return redirect()->back()->withErrors(['send_request' => $e->getMessage()]);
}
}
}
Notice:
- Type-hint the Form Request class instead of base
Request - Laravel automatically runs all validation before the method executes
- Controller focuses purely on business logic
- No validation code cluttering the controller
Handling Errors in Vue Components
Inertia automatically makes errors available in your Vue components:
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3'
const form = useForm({
settings: {
show_comments_tab: true,
show_activity_tab: true,
allow_friend_requests: true,
}
})
const submit = () => {
form.post(route('profile.privacy.update', { user: props.user.id }), {
preserveScroll: true,
onSuccess: () => {
// Form submitted successfully
},
})
}
</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>
<!-- Inertia automatically provides field-specific errors -->
<p v-if="form.errors[`settings.${key}`]" class="error">
{{ form.errors[`settings.${key}`] }}
</p>
</div>
<!-- General form errors -->
<p v-if="form.errors.settings" class="error">
{{ form.errors.settings }}
</p>
<button type="submit" :disabled="form.processing">
Save Settings
</button>
</form>
</template>
Key Features:
useForm()provides reactive form state and error handlingform.errorsautomatically populated from Laravel validationform.processingtracks submission statepreserveScrollkeeps user position on validation errors- Errors are field-specific:
form.errors['settings.show_comments_tab']
Under the Hood
How Laravel Processes Form Requests
Step 1: Dependency Injection Resolution
When you type-hint a Form Request in a controller method, Laravel’s service container resolves it through the FormRequestServiceProvider:
// Laravel's internal process (simplified)
$formRequest = app()->make(SendFriendRequestRequest::class);
Step 2: Authorization Check
Before validation, Laravel calls authorize():
if (!$formRequest->authorize()) {
throw new AuthorizationException('This action is unauthorized.');
}
This happens in Illuminate\Foundation\Http\FormRequest::validateResolved().
Step 3: Validation Execution
Laravel creates a Validator instance using the rules:
$validator = Validator::make(
$request->all(),
$formRequest->rules(),
$formRequest->messages(),
$formRequest->attributes()
);
Step 4: Custom Validation Hook
If withValidator() exists, Laravel calls it:
if (method_exists($formRequest, 'withValidator')) {
$formRequest->withValidator($validator);
}
This allows you to add custom logic after basic rules but before validation runs.
Step 5: Validation Failure Handling
If validation fails, Laravel automatically:
- For Inertia requests: Returns a 422 response with errors in the session
- For API requests: Returns JSON with error details
- For traditional requests: Redirects back with errors flashed to session
if ($validator->fails()) {
throw new ValidationException($validator);
}
Memory and Performance Considerations
Form Request Lifecycle:
- Form Requests are instantiated per request (not singletons)
- They’re garbage collected after the response is sent
- Validation rules are compiled once and cached in production
Performance Tips:
- Avoid heavy operations in
rules(): This method is called on every request - Use
sometimesfor conditional rules: Reduces unnecessary validation - Leverage database query caching: In
withValidator(), cache existence checks
// ❌ BAD: Queries database on every validation
public function rules(): array
{
return [
'user_id' => [
'required',
Rule::exists('users', 'id')->where('active', true),
],
];
}
// ✅ GOOD: Use withValidator for complex checks
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$userId = $this->input('user_id');
// This only runs if basic validation passes
$user = Cache::remember("user.{$userId}", 60, function () use ($userId) {
return User::find($userId);
});
if (!$user || !$user->active) {
$validator->errors()->add('user_id', 'Invalid user.');
}
});
}
How Inertia Handles Validation Errors
Client-Side Flow:
- Form submission: Inertia intercepts the form submit
- XHR request: Sends data via AJAX with
X-Inertiaheader - Validation failure: Laravel returns 422 with errors
- Error injection: Inertia updates the page props with errors
- Reactive update: Vue automatically re-renders with error messages
The magic header:
// Inertia adds this header to all requests
headers: {
'X-Inertia': true,
'X-Inertia-Version': pageVersion,
}
Laravel’s HandleInertiaRequests middleware detects this and formats responses accordingly.
Edge Cases & Pitfalls
Authorization vs. Validation
Problem: Confusing authorization with validation logic.
// ❌ BAD: Validation logic in authorize()
public function authorize(): bool
{
$user = $this->route('user');
return auth()->id() !== $user->id; // This is validation, not authorization!
}
// ✅ GOOD: Authorization checks permissions, validation checks data
public function authorize(): bool
{
return auth()->check(); // Can the user attempt this action?
}
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$user = $this->route('user');
if (auth()->id() === $user->id) {
$validator->errors()->add('user', 'Cannot perform action on yourself.');
}
});
}
Rule of thumb: authorize() checks who can do this, withValidator() checks if the data makes sense.
N+1 Queries in Validation
Problem: Running database queries for each array item.
// ❌ BAD: N+1 query problem
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$userIds = $this->input('user_ids', []);
foreach ($userIds as $userId) {
// This queries the database N times!
if (!User::find($userId)) {
$validator->errors()->add("user_ids.{$userId}", 'User not found.');
}
}
});
}
// ✅ GOOD: Single query with whereIn
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$userIds = $this->input('user_ids', []);
// Single query for all IDs
$existingIds = User::whereIn('id', $userIds)->pluck('id')->toArray();
$missingIds = array_diff($userIds, $existingIds);
foreach ($missingIds as $missingId) {
$validator->errors()->add("user_ids.{$missingId}", 'User not found.');
}
});
}
Forgetting to Type-Hint
Problem: Using base Request instead of your Form Request.
// ❌ BAD: Validation never runs!
public function store(Request $request)
{
// SendFriendRequestRequest validation is bypassed
$this->friendService->sendRequest($request->user(), $request->route('user'));
}
// ✅ GOOD: Type-hint your Form Request
public function store(SendFriendRequestRequest $request, User $user)
{
// Validation automatically runs before this method executes
$this->friendService->sendRequest($request->user(), $user);
}
Handling File Uploads
Form Requests work seamlessly with file uploads:
public function rules(): array
{
return [
'profile_picture' => [
'required',
'image',
'mimes:jpeg,png,jpg,gif',
'max:2048', // 2MB max
],
];
}
public function withValidator($validator): void
{
$validator->after(function ($validator) {
if ($this->hasFile('profile_picture')) {
$file = $this->file('profile_picture');
// Custom validation: check image dimensions
$image = getimagesize($file->getRealPath());
if ($image[0] < 200 || $image[1] < 200) {
$validator->errors()->add(
'profile_picture',
'Image must be at least 200x200 pixels.'
);
}
}
});
}
Conditional Validation Rules
Use sometimes for rules that only apply in certain contexts:
public function rules(): array
{
return [
'email' => [
'required',
'email',
// Only check uniqueness when creating, not updating
Rule::unique('users')->ignore($this->route('user')),
],
'password' => [
// Password only required when creating
$this->isMethod('POST') ? 'required' : 'sometimes',
'min:8',
'confirmed',
],
];
}
Conclusion
What You’ve Learned
You’ve mastered production-grade form validation using Laravel Form Requests and Inertia.js:
- Separation of Concerns: Validation logic lives in dedicated classes, not controllers
- Three-Layer Defense: Authorization → Basic Rules → Business Logic
- Reusability: Same Form Request works for web and API routes
- Type Safety: Type-hinted Form Requests ensure validation always runs
- Seamless Integration: Inertia automatically handles errors in Vue components
- Performance: Efficient validation with query optimization and caching
The Production Pattern
Form Request Class
├── authorize() → Who can do this?
├── rules() → Is the data valid?
├── withValidator() → Does it make business sense?
└── messages() → Custom error text
Controller
└── Type-hint Form Request → Validation runs automatically
Vue Component
└── useForm() → Reactive errors from Inertia
Real-World Impact
In MyKpopLists, this pattern:
- Reduced controller code by 60%: Validation extracted to reusable classes
- Eliminated validation bugs: Consistent rules across all endpoints
- Improved UX: Field-specific errors with custom messages
- Simplified testing: Test validation independently from controllers
Next Steps
- Tutorial 8: Build a search system with filtering and pagination
- Tutorial 9: Implement soft deletes with cascade logic and content moderation
- Advanced: Create custom validation rules with
php artisan make:rule - Testing: Write feature tests for Form Requests using Pest PHP
Remember: Form Requests are your first line of defense. Never trust user input, always validate server-side, and keep your controllers clean.