In a technical planning session, a colleague from our frontend team showed us Laravel Precognition and asked whether something like it existed for Symfony? There wasn't. So we built it — first inside our project, then decided to open-source it as a bundle: fundraisingbox/symfony-precognition.

The main use case is live form validation. A donor fills out a form, and we want to tell them while they type that the input is wrong, not after they hit submit.

Our first instinct was to port the validation rules to the frontend and check everything client-side. That gives you instant feedback and no network round-trip — and it also gives you two implementations of the same rules that have to agree forever. Symfony constraints live on the DTO, in attributes, with groups, custom constraints, and callbacks. There is no automated way to compile that into JavaScript today. Every rule you add on the backend becomes a rule someone has to remember to mirror. In our experience that "someone" eventually forgets, and the frontend happily accepts input the backend then rejects.

So the rules stay where they belong, on the server, and the frontend asks the server what it thinks.

A precognitive request is a normal request with one extra header:

Precognition: true

The server resolves and validates the controller arguments exactly as it normally would — and then stops, before the controller body ever runs.

Nothing is created. Nothing is mutated. No mail is sent, no data is written to the database. The client receives feedback whether the payload would be accepted, and it is based on the only implementation of the rules that exists.

A short example

Given a typical controller with an ordinary validated payload. The only addition is the #[Precognitive] attribute:

use FundraisingBox\Precognition\Attribute\Precognitive;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Constraints as Assert;

final class UserDto
{
    public function __construct(
        #[Assert\NotBlank]
        public string $firstName = '',

        #[Assert\GreaterThan(18)]
        public int $age = 0,
    ) {
    }
}

final class UserController
{
    #[Route('/user', methods: ['POST'])]
    #[Precognitive]
    public function create(#[MapRequestPayload] UserDto $user): Response
    {
        // Never runs for a precognitive request.
    }
}

Send the request you would send anyway, plus the header:

curl -i -X POST https://example.test/user \
  -H 'Content-Type: application/json' \
  -H 'Precognition: true' \
  -d '{"firstName":"Clemens","age":42}'
HTTP/1.1 204 No Content
Precognition: true
Precognition-Success: true
Vary: Precognition

With "age": 17 you get the application's regular 422 and Symfony's violation list.

On the client, you would typically add a field check on blur.

const response = await fetch(ENDPOINT, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Accept: 'application/json',
    Precognition: 'true',
    'Precognition-Validate-Only': 'age',
  },
  body: JSON.stringify(payload),
});

if (response.status === 204) {
  // valid
}

Precognition-Validate-Only takes a comma-separated list of property paths and limits which violations are reported. That matters for live validation: while someone is still typing their first name you don't want to shout at them about the empty ZIP code field. Paths are matched by prefix, so address also covers address.zipCode.

The repository ships a dependency-free vanilla JS example with debouncing, request cancellation via AbortController, and a final precognitive check before the real submit.

Classic Symfony Forms work too, via
#[PrecognitiveForm(TaskType::class)].

The bundle builds a throwaway instance of the form type, submits the payload to it, and maps the form errors back to constraint violations.

What it deliberately does not do

Two boundaries are worth stating plainly, because both are easy to get wrong.

Routes are opt-in. Without #[Precognitive] or #[PrecognitiveForm], the Precognition header does nothing at all and the controller runs normally. A bundle that silently short-circuits every route in an application the moment somebody sends a header would be a fairly effective footgun. Global mode exists (precognition.allow_all_routes: true), but you have to ask for it. This is also why clients should check the Precognition header on the response and not just trust the status code.

A 204 is not a promise. Only validation that happens during argument resolution runs. Business rules inside the controller — uniqueness checks against the database, payment provider calls, authorization on the entity — do not. 204 means "this payload is structurally valid", not "this operation would succeed". Precognition is a fast feedback loop, not a dry run of your domain logic.

This started as a handful of event listeners inside one application. It worked, it was useful, and it was — as these things go — entangled with that application's assumptions. Pulling it out forced the interesting questions: Should opt-in be the default? Where in the kernel lifecycle does filtering actually belong? What happens with #[MapQueryString], which fails with 404 rather than 422?

The result is a small bundle with a documented event flow, functional tests against a real kernel, and support for Symfony 6.4 through 8.

Thanks to my employer FundraisingBox for backing the open-source release and paying for the time it took to do it properly. We realize that we benefit from open-source ourselves so much, so it feels natural to give back with contributions. A special thanks to my lovely colleagues for reviewing it, too!

A note on the Laravel client SDKs

The bundle implements the same request and success protocol as Laravel Precognition, and the official Laravel frontend SDKs will appear to work with it. They won't show field errors, though: they read response.data.errors in Laravel's shape, while Symfony returns its native violations array. That's a deliberate trade-off — Symfony applications should keep Symfony's error format — and the repository documents a bridge recipe if you want to use the existing SDKs anyway.

Try it

composer require fundraisingbox/symfony-precognition

Symfony Flex registers the bundle for you. Add #[Precognitive] to one route and send a Precognition: true header — that is the entire setup. The docs cover request payloads, query strings, uploaded files, Symfony Forms, partial validation, and CORS.

Source and issues: github.com/FundraisingBox/symfony-precognition. Feedback and pull requests are welcome - just no vibe-coded PR floods please.