- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You have two copies of the same rules. One lives in a StoreUserRequest on the server. The other lives in a Zod schema, or a Yup object, or a pile of required attributes, on the front end. They started identical. Then someone bumped the password minimum from 8 to 12 on the backend and forgot the client. Now the form says the password is fine, the user clicks submit, and a 422 bounces back with an error the UI never predicted.
That drift is the whole reason live client-side validation is annoying to maintain. You are keeping two rulesets in sync by hand, and the sync breaks quietly. Laravel Precognition removes the second copy. The front end asks the server "would this pass?" before the user submits, and the server answers using the exact same validation rules the real request will run.
What a precognitive request actually is
A precognitive request is a normal HTTP request to your real endpoint, tagged with a Precognition: true header. Laravel sees the header, runs the route's middleware and validation, and then stops before your controller does any real work. It never writes a row. It never sends an email. It runs the rules and returns the verdict.
Success comes back as 204 No Content with a Precognition-Success: true header. Failure comes back as a normal 422 with the same JSON error bag your form submit would produce. Same rules, same messages, same field names. There is no second schema to drift.
The lifecycle is worth holding in your head:
- Front end sends the form state to the real URL with
Precognition: true. - Middleware runs.
FormRequestvalidation runs. - Laravel short-circuits: your controller body never executes.
- Response is 204 (valid) or 422 (invalid).
The controller is skipped by design. That is what makes it safe to fire on every keystroke.
Wiring the backend
You need two things: the middleware on the route, and a form request that already holds your rules. If you have the form request, you are most of the way there.
use App\Http\Requests\StoreUserRequest;
use Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests;
Route::post('/users', [UserController::class, 'store'])
->middleware(HandlePrecognitiveRequests::class);
The form request is unchanged. This is the same class your real POST already validates against:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
class StoreUserRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:120'],
'email' => ['required', 'email', 'max:255'],
'password' => [
'required',
'confirmed',
Password::min(12)->mixedCase()->numbers(),
],
];
}
}
One ruleset. The submit path uses it. The live-check path uses it. When you raise the password minimum, both paths move together because there is only one place to edit.
The live check on the front end
Laravel ships a small client per framework: laravel-precognition-vue, laravel-precognition-react, and laravel-precognition-alpine, plus Inertia variants. Install the one that matches your stack.
npm install laravel-precognition-vue
The useForm helper wraps your form state and talks to the endpoint for you. It looks like a form library because it is one, with a validation channel wired to the server:
import { useForm } from 'laravel-precognition-vue';
const form = useForm('post', '/users', {
name: '',
email: '',
password: '',
password_confirmation: '',
});
const submit = () => form.submit();
You trigger a live check by calling form.validate('field') when the user leaves the field. The client debounces the calls and sends a precognitive request. Errors land in form.errors, keyed by the same field names your rules() method uses.
<input
v-model="form.email"
@change="form.validate('email')"
/>
<span v-if="form.invalid('email')">
{{ form.errors.email }}
</span>
No Zod schema. No hand-copied minLength. The message under the input is the message your Password::min(12) rule generates, rendered before the user ever submits.
Validating only what the user has touched
If the front end sent the whole form on the first keystroke, the server would reject every empty required field and the form would light up red before the user typed anything. Precognition avoids that with a Precognition-Validate-Only header. When you call form.validate('email'), the client tells the server to run only the rules for email. The other fields are left alone until they are touched.
You can read the touched state and gate rules server-side too. On the request, isPrecognitive() tells you whether this is a dry run:
public function store(StoreUserRequest $request)
{
// Skipped entirely on precognitive requests.
$user = User::create($request->validated());
event(new UserRegistered($user));
return redirect()->route('users.show', $user);
}
Because the controller body never runs during a precognitive request, User::create and the event are safe. If you have an expensive rule you only want on the real submit, gate it inside rules() with isPrecognitive():
public function rules(): array
{
return [
'password' => $this->isPrecognitive()
? [Password::min(12)]
: [Password::min(12)->uncompromised()],
];
}
The cheap Password::min(12) check runs on every live keystroke. The ->uncompromised() breach lookup runs only on the real submit, where the extra cost is worth it.
Where the domain rules still belong
Here is the line that matters, and it is easy to cross once live validation feels this good. Precognition is a validation channel. It answers "is this input the right shape?" It does not answer "is this operation allowed to happen right now?" Those are different questions, and the second one does not belong at the edge.
Take unique:users,email. You can put it in rules() and Precognition will happily run it on every debounced keystroke. That gives the user a nice "email already taken" hint while typing. Useful. But it is a hint, not a guarantee. Between the moment the live check passes and the moment the real submit commits, another request can insert the same email. The check that actually protects you is the unique constraint on the database column, enforced inside the write transaction. The live rule is UX. The constraint is truth.
The same split holds for anything with business weight: a credit limit that depends on the current account balance, a booking that can only exist if the slot is still free, a discount that is valid only during a window. These are invariants of your domain. They have to be checked at the moment of commit, under a transaction, against the current state. Running them as validation rules on a keystroke tells the user something probably true. It cannot be the thing you rely on.
A workable division:
- Validation rules (reused by Precognition): format, length, presence, type, matching confirmation, allowed enum values. Cheap, stateless, safe to run repeatedly.
- Domain invariants (enforced at commit): uniqueness under concurrency, cross-entity consistency, state-dependent rules, authorization tied to current data. Enforced once, transactionally, where the write happens.
Precognition gives you one source of truth for the first list. It is not a place to move the second. When you push a stock check or a balance rule into a form request so the front end can preview it, you have quietly relocated a domain decision into an HTTP adapter, and it will drift from the real enforcement point the same way the old Zod schema drifted from your backend rules. You solved the duplication problem in one layer by recreating it in another.
Keep the format rules in the form request, reuse them live, and let the domain enforce its own invariants where the transaction lives. The user gets instant feedback on shape. The system keeps its guarantees where guarantees can actually be held.
If this was useful
Precognition is a clean example of the boundary the whole book is about: the form request is an adapter that translates HTTP into a shape your application can accept, and it is a fine place to reuse validation rules. It is the wrong place to put the decision your domain has to own. When the rule is "is this input well-formed," reuse it at the edge. When the rule is "is this operation valid against current state," it belongs in the use case, checked once at commit. Decoupled PHP is about drawing that line and keeping it drawn as the framework features get more tempting.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (1)
I really like the way you separated input validation from business rules. Reusing the same validation rules for live feedback is a huge win, but keeping transactional guarantees inside the domain is what prevents subtle bugs. Nice explanation. Looking forward to reading more of your thoughts on architecture—and hopefully getting a chance to collaborate someday.