You have a registration form. The user fills in an email that's already taken, tabs through four more fields, hits submit, and only then gets told the email is a duplicate. The fix is "live" validation, but the usual way to do it means re-implementing your unique:users,email rule in JavaScript — and now you have two sources of truth that drift apart the moment someone edits one and forgets the other.
Laravel Precognition removes the second copy entirely. It runs your actual backend validation rules as the user types, using the exact same Form Request you'd use on the real submit. No duplicated rules, no full round trip through your controller. Here's the full wiring with Alpine and Blade, end to end.
Add the HandlePrecognitiveRequests middleware to the route#
Precognition works by sending a special "precognitive" request to the same route your form submits to. When Laravel sees the Precognition header, it runs the route's middleware and resolves the controller's dependencies — including validating any Form Request — but it stops short of executing the controller body. To switch that behaviour on, add the HandlePrecognitiveRequests middleware to the route definition.
use App\Http\Requests\StoreUserRequest;
use Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests;
Route::post('/register', function (StoreUserRequest $request) {
// This body never runs during a precognitive request.
User::create($request->validated());
return redirect()->route('dashboard');
})->middleware([HandlePrecognitiveRequests::class]);
The route still works exactly as before for a normal POST. The middleware only changes what happens when a request arrives carrying the Precognition header, which the frontend helper adds for you. Because the real submit is still a standard form POST, your existing CSRF and request-forgery protection applies unchanged — Precognition doesn't bypass any of it.
Move validation rules into a Form Request#
The whole point is one set of rules. So they need to live somewhere both the live validation and the final submit can read them — that's a Form Request. If your validation is currently inline in the controller, extract it first.
php artisan make:request StoreUserRequest
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
class StoreUserRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:users,email'],
'password' => ['required', Password::min(8)],
];
}
}
Now the unique:users,email rule and the password policy exist in exactly one place. Both the live, as-you-type validation and the real submit run this same rules() method. Change a rule here and both paths pick it up — there's no second copy to forget.
Install the Precognition Alpine frontend helper#
Laravel ships a small frontend package per stack. For Alpine, install laravel-precognition-alpine and register it as an Alpine plugin. This is the piece that adds the Precognition header, debounces the requests, and exposes the form state to your Blade markup.
npm install laravel-precognition-alpine
Then register the plugin in resources/js/app.js, before Alpine.start():
import Alpine from 'alpinejs';
import Precognition from 'laravel-precognition-alpine';
window.Alpine = Alpine;
Alpine.plugin(Precognition);
Alpine.start();
Rebuild your assets with npm run build (or npm run dev while developing) so the plugin is bundled. Once it's registered, every Alpine component on the page gains access to the $form magic you'll use next. If you're already running Alpine through Livewire's bundled copy, see the gotcha at the end — you don't register it twice.
Wire Precognition to each input's change event#
With the plugin registered, create the form object in x-data using $form, passing the HTTP method, the target URL, and the initial field values. Bind each input with x-model, then call form.validate('field') on the input's change event. That's what fires the debounced precognitive request for that single field.
<form x-data="{
form: $form('post', '/register', {
name: '',
email: '',
password: '',
}),
}" @submit.prevent="$el.submit()">
@csrf
<label for="email">Email</label>
<input
id="email"
name="email"
type="email"
x-model="form.email"
@change="form.validate('email')"
/>
<template x-if="form.invalid('email')">
<p class="text-red-600" x-text="form.errors.email"></p>
</template>
<button type="submit" :disabled="form.processing">Create account</button>
</form>
When the user changes the email field and tabs out, the helper posts { email: '...' } to /register with the Precognition header. Your StoreUserRequest validates it, including the database unique check, and returns just the errors — your controller never runs, so no user is created. The form.errors object is populated automatically, and form.invalid('email') flips to true.
Show field errors and a debounced loading state#
The form object already exposes everything you need for feedback: form.errors for messages, form.invalid() and form.valid() per field, form.validating while a request is in flight, and form.hasErrors for the whole form. Use them to show a spinner during validation and a tick once a field passes. You can tune the debounce window with setValidationTimeout, which takes milliseconds.
<form x-data="{
form: $form('post', '/register', {
name: '',
email: '',
password: '',
}).setValidationTimeout(500),
}">
@csrf
<label for="email">Email</label>
<input id="email" name="email" type="email"
x-model="form.email" @change="form.validate('email')" />
<template x-if="form.validating">
<span class="text-sm text-gray-500">Checking…</span>
</template>
<template x-if="form.valid('email')">
<span class="text-green-600">✓</span>
</template>
<template x-if="form.invalid('email')">
<p class="text-red-600" x-text="form.errors.email"></p>
</template>
</form>
A field only reports as valid or invalid once it has changed and a response has come back, so you won't get a wall of red on an untouched form. The default debounce is 1500ms; dropping it to around 500ms feels responsive for a unique check without hammering the endpoint on every keystroke.
Gotchas and Edge Cases#
Don't validate expensive rules on every keystroke. Some rules are fine to run on the final submit but wasteful — or wrong — to run live. The classic case is Password::min(8)->uncompromised(), which calls the Have I Been Pwned API. Use the request's isPrecognitive() method inside rules() to relax the rule for live validation and tighten it for the real submit.
public function rules(): array
{
return [
'password' => [
'required',
$this->isPrecognitive()
? Password::min(8)
: Password::min(8)->uncompromised(),
],
];
}
Files aren't uploaded during live validation. By default Precognition skips file fields on precognitive requests so it isn't re-uploading a large avatar on every change. Make file rules conditional with isPrecognitive() — ...$this->isPrecognitive() ? [] : ['required'] — or call form.validateFiles() on the client if you genuinely want them validated live.
Watch for side-effects in other middleware. Precognitive requests run the full middleware stack. If you have middleware that increments a counter, logs an action, or rate-limits, guard it with if (! $request->isPrecognitive()) so a few keystrokes don't pollute your metrics or burn a user's rate limit.
On Livewire, you usually don't need Precognition at all. Precognition is for the Alpine-and-Blade or Vue/React stacks where the form posts traditionally. If your form already lives inside a Livewire component, its own real-time validation is the better fit — see Livewire 4 Form Objects with #[Validate] for the per-field wire:model.live.blur pattern, which covers the same ground without a separate frontend package. And if you're keeping ephemeral UI state alongside it, Alpine's wire:entangle is how the two layers share state. Reach for Precognition when you're deliberately not on Livewire for that form.
Repopulate old input on a traditional submit. Because the real submit is a normal POST, a failed submission redirects back. Seed the form with old() values and the server-side error bag so the Alpine form picks up where the browser left off: $form('post', '/register', { email: '{{ old('email') }}' }).setErrors({{ Js::from($errors->messages()) }}).
Wrapping Up#
Add the HandlePrecognitiveRequests middleware, push your rules into a Form Request, install laravel-precognition-alpine, and validate on each input's change event. The result is live, accurate validation that can never drift from what the server actually enforces, because it is what the server enforces. Use isPrecognitive() to keep the slow rules off the live path.
For testing, Laravel's withPrecognition() helper and the assertSuccessfulPrecognition() assertion let you cover the precognitive path directly — fold those into your wider Pest architecture and feature testing suite. And if you're weighing this against doing the form in Livewire instead, the Livewire 3 to 4 migration guide is the place to start on that side of the fence.
FAQ#
What is Laravel Precognition?
Laravel Precognition is a first-party feature that lets the frontend anticipate the result of a future HTTP request — most commonly, to provide live form validation. When a "precognitive" request hits a route protected by the HandlePrecognitiveRequests middleware, Laravel runs the middleware and validates the route's Form Request, then returns the validation result without executing the controller. It ships with frontend helper packages for Vue, React, and Alpine.
How does Precognition avoid duplicating validation rules?
Your rules live in a single Form Request on the backend. The live, as-you-type validation sends a precognitive request to the same route, which runs that same Form Request, and so does the final submit. There is only ever one copy of the rules, written once in PHP. The frontend never reimplements unique, email, or any other rule — it just renders whatever errors the server returns.
Does Precognition run my controller logic?
No. A precognitive request executes the route's middleware and resolves the controller's dependencies — which is what triggers Form Request validation — but it deliberately does not run the controller method body. That means no records are created, no emails are sent, and no side-effects fire during live validation. The controller only runs on the real, non-precognitive submission.
Can I use Laravel Precognition with Alpine instead of Vue or React?
Yes. Install the laravel-precognition-alpine package and register it with Alpine.plugin(Precognition) in your app.js. You then create a form object with the $form magic in x-data, bind inputs with x-model, and call form.validate('field') on each input's change event. It works with plain Blade and needs no SPA framework.
How do I debounce precognitive validation requests?
The Alpine form object debounces validation requests automatically, with a default timeout of 1500 milliseconds. Change it by calling setValidationTimeout on the form, passing the number of milliseconds — for example $form(...).setValidationTimeout(500). A value around 500ms keeps a database unique check feeling responsive without sending a request on every individual keystroke.