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

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.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!