Laravel Precognition: Real-Time Validation Without Duplicating Rules

· 7 min read

Form validation is the most duplicated code in web development. You write rules in Laravel, then rewrite them in JavaScript. When the rules change, you update both — or forget and create bugs.

Laravel Precognition eliminates this by letting your frontend ask the backend to validate individual fields in real-time, reusing your existing Form Request rules.

How It Works

User types email → Frontend sends partial request → Backend validates ONLY that field
                                                   → Returns validation result
                                                   → Frontend shows error instantly

No full form submission. No page reload. No duplicated rules.

Installation

Backend

composer require laravel/precognition

Frontend (choose your stack)

# Vue
npm install laravel-precognition-vue

# React
npm install laravel-precognition-react

# Alpine.js
npm install laravel-precognition-alpine

Backend Setup

1. Add the Middleware

// routes/web.php
use App\Http\Controllers\RegistrationController;

Route::post('/register', [RegistrationController::class, 'store'])
    ->middleware('precognitive');

The precognitive middleware tells Laravel: "If this is a precognitive request, validate and return early — don't execute the controller logic."

2. Use a Form Request (You Probably Already Have One)

// app/Http/Requests/RegisterRequest.php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;

class RegisterRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'email', 'unique:users,email'],
            'username' => ['required', 'string', 'min:3', 'max:30', 'unique:users,username', 'alpha_dash'],
            'password' => ['required', 'confirmed', Password::min(8)->mixedCase()->numbers()],
        ];
    }

    public function messages(): array
    {
        return [
            'username.unique' => 'This username is already taken.',
            'email.unique' => 'An account with this email already exists.',
        ];
    }
}

3. Use It in the Controller

// app/Http/Controllers/RegistrationController.php

class RegistrationController extends Controller
{
    public function store(RegisterRequest $request)
    {
        // This code ONLY runs on real submissions, not precognitive requests
        $user = User::create($request->validated());

        Auth::login($user);

        return redirect('/dashboard');
    }
}

That's it for the backend. Your existing Form Request works as-is.

Frontend: Vue 3

<script setup>
import { useForm } from 'laravel-precognition-vue';

const form = useForm('post', '/register', {
    name: '',
    email: '',
    username: '',
    password: '',
    password_confirmation: '',
});

const submit = () => form.submit({
    onSuccess: () => window.location.href = '/dashboard',
});
</script>

<template>
    <form @submit.prevent="submit">
        <div>
            <label>Name</label>
            <input
                v-model="form.name"
                @change="form.validate('name')"
            />
            <span v-if="form.invalid('name')" class="text-red-500">
                {{ form.errors.name }}
            </span>
        </div>

        <div>
            <label>Email</label>
            <input
                v-model="form.email"
                @change="form.validate('email')"
                type="email"
            />
            <span v-if="form.invalid('email')" class="text-red-500">
                {{ form.errors.email }}
            </span>
            <span v-if="form.valid('email')" class="text-green-500">
                ✓ Available
            </span>
        </div>

        <div>
            <label>Username</label>
            <input
                v-model="form.username"
                @change="form.validate('username')"
            />
            <span v-if="form.invalid('username')" class="text-red-500">
                {{ form.errors.username }}
            </span>
        </div>

        <div>
            <label>Password</label>
            <input
                v-model="form.password"
                @change="form.validate('password')"
                type="password"
            />
            <span v-if="form.invalid('password')" class="text-red-500">
                {{ form.errors.password }}
            </span>
        </div>

        <div>
            <label>Confirm Password</label>
            <input
                v-model="form.password_confirmation"
                @change="form.validate('password')"
                type="password"
            />
        </div>

        <button :disabled="form.processing" type="submit">
            Register
        </button>
    </form>
</template>

Key points:

  • @change="form.validate('email')" — triggers validation for that single field when the user leaves the input
  • form.invalid('email') — checks if the field has errors
  • form.valid('email') — checks if the field has been validated and passed
  • The form handles debouncing automatically

Frontend: Alpine.js

For Blade-first apps:

<form
    x-data="{
        form: $form('post', '/register', {
            name: '',
            email: '',
            username: '',
            password: '',
            password_confirmation: '',
        })
    }"
    @submit.prevent="form.submit()"
>
    <div>
        <label>Email</label>
        <input
            x-model="form.email"
            @change="form.validate('email')"
            type="email"
        >
        <template x-if="form.invalid('email')">
            <p x-text="form.errors.email" class="text-red-500 text-sm"></p>
        </template>
        <template x-if="form.valid('email')">
            <p class="text-green-500 text-sm">✓ Available</p>
        </template>
    </div>

    <!-- Other fields... -->

    <button :disabled="form.processing" type="submit">
        Register
    </button>
</form>

Debouncing & Timing

By default, Precognition debounces requests at 1500ms (typing) and 0ms (on change). Customize it:

const form = useForm('post', '/register', {
    email: '',
});

// Validate while typing (with debounce)
form.setValidationTimeout(500); // 500ms debounce
<!-- Validate on input (while typing) instead of on change -->
<input
    v-model="form.email"
    @input="form.validate('email')"
    type="email"
/>

Handling Side Effects

Sometimes validation has side effects you want to skip during precognitive requests:

class StoreOrderRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'coupon_code' => ['sometimes', 'string', 'exists:coupons,code'],
            'items' => ['required', 'array', 'min:1'],
        ];
    }

    protected function prepareForValidation(): void
    {
        // Skip coupon lookup during precognitive validation
        if ($this->isPrecognitive()) {
            return;
        }

        // Only on real submission
        $this->merge([
            'discount' => $this->calculateDiscount(),
        ]);
    }
}

Validating Specific Fields Only

Precognition only validates the fields the frontend asks about. But sometimes you need conditional logic:

public function rules(): array
{
    return [
        'type' => ['required', 'in:personal,business'],
        'company_name' => ['required_if:type,business', 'string', 'max:255'],
        'tax_id' => ['required_if:type,business', 'string'],
        'name' => ['required', 'string', 'max:255'],
    ];
}

When the frontend validates type, Precognition knows to also validate dependent fields.

With Inertia.js

Precognition works seamlessly with Inertia:

<script setup>
import { useForm } from 'laravel-precognition-vue-inertia';

const form = useForm('post', '/posts', {
    title: '',
    body: '',
    category_id: null,
});

const submit = () => form.submit({
    preserveScroll: true,
    onSuccess: () => form.reset(),
});
</script>

File Uploads

Precognition supports file validation too:

public function rules(): array
{
    return [
        'avatar' => ['required', 'image', 'max:2048', 'dimensions:min_width=100,min_height=100'],
    ];
}
<input
    type="file"
    @change="e => {
        form.avatar = e.target.files[0];
        form.validate('avatar');
    }"
/>
<span v-if="form.invalid('avatar')">{{ form.errors.avatar }}</span>

The file is sent to the backend for validation — size, dimensions, MIME type — all checked server-side.

Performance Considerations

Each field validation is an HTTP request. Optimize:

  1. Use @change not @input for expensive rules (like unique)
  2. Debounce appropriately — 500ms for typing, 0ms for dropdowns
  3. Group related validations:
// Validate multiple fields at once
form.validate(['password', 'password_confirmation']);
  1. Cache database checks when possible:
public function rules(): array
{
    return [
        // Use rate limiting on unique checks if needed
        'email' => [
            'required',
            'email',
            Rule::unique('users')->ignore($this->user()?->id),
        ],
    ];
}

Testing

public function test_precognitive_validation_for_email(): void
{
    User::factory()->create(['email' => 'taken@example.com']);

    $this->post('/register', [
        'email' => 'taken@example.com',
    ], [
        'Precognition' => 'true',
        'Precognition-Validate-Only' => 'email',
    ])->assertStatus(422)
      ->assertJsonValidationErrors('email');
}

public function test_precognitive_success(): void
{
    $this->post('/register', [
        'email' => 'new@example.com',
    ], [
        'Precognition' => 'true',
        'Precognition-Validate-Only' => 'email',
    ])->assertStatus(204); // No errors
}

Conclusion

Laravel Precognition gives you real-time form validation with zero rule duplication. Your Form Requests are the single source of truth.

When to use it:

  • Registration/signup forms with uniqueness checks
  • Multi-step forms where each step needs validation
  • Complex forms with conditional rules
  • Any form where instant feedback matters

When not to use it:

  • Simple forms with only frontend-checkable rules (required, min length)
  • Forms that rarely fail validation
  • High-traffic forms where server load is a concern (use client-side validation instead)

Comments