featured image

Form Validation with Laravel Form Requests and Inertia

Master production-grade form validation using Laravel Form Requests combined with Inertia.js for seamless frontend-backend integration. Learn advanced techniques for authorization, custom validation logic, and real-time error handling in Vue components.

Published

Mon Nov 10 2025

Technologies Used

Laravel Vue.js Inertia.js PHP
Beginner 17 minutes

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:

  1. Gate 1 - Authorization (authorize()): “Are you allowed to even attempt this?”

    • Like a bouncer checking your ID before you enter
  2. Gate 2 - Basic Validation (rules()): “Is your data in the right format?”

    • Like a form checker ensuring all required fields are filled
  3. 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 returns false, Laravel throws a 403 Forbidden
  • rules() 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 errors
  • match() 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 handling
  • form.errors automatically populated from Laravel validation
  • form.processing tracks submission state
  • preserveScroll keeps 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:

  1. For Inertia requests: Returns a 422 response with errors in the session
  2. For API requests: Returns JSON with error details
  3. 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:

  1. Avoid heavy operations in rules(): This method is called on every request
  2. Use sometimes for conditional rules: Reduces unnecessary validation
  3. 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:

  1. Form submission: Inertia intercepts the form submit
  2. XHR request: Sends data via AJAX with X-Inertia header
  3. Validation failure: Laravel returns 422 with errors
  4. Error injection: Inertia updates the page props with errors
  5. 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:

  1. Separation of Concerns: Validation logic lives in dedicated classes, not controllers
  2. Three-Layer Defense: Authorization → Basic Rules → Business Logic
  3. Reusability: Same Form Request works for web and API routes
  4. Type Safety: Type-hinted Form Requests ensure validation always runs
  5. Seamless Integration: Inertia automatically handles errors in Vue components
  6. 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.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!