DEV Community

Cover image for Laravel Precognition: Live Validation That Reuses Your Backend Rules
Gabriel Anhaia
Gabriel Anhaia

Posted on

Laravel Precognition: Live Validation That Reuses Your Backend Rules


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:

  1. Front end sends the form state to the real URL with Precognition: true.
  2. Middleware runs. FormRequest validation runs.
  3. Laravel short-circuits: your controller body never executes.
  4. 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);
Enter fullscreen mode Exit fullscreen mode

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(),
            ],
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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()],
    ];
}
Enter fullscreen mode Exit fullscreen mode

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.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (1)

Collapse
 
daniel_trix_smith profile image
Daniel Trix Smith

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.