Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Symfony Precognition Laravel Package

fundraisingbox/symfony-precognition

Symfony bundle that brings Precognition-style request validation to Symfony apps, letting frontends preview form errors before submission without running the controller, for faster feedback and fewer failed submissions.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install and register

composer require fundraisingbox/symfony-precognition:^1.0
// config/bundles.php
return [
    // ...
    FundraisingBox\SymfonyPrecognition\PrecognitionBundle::class => ['all' => true],
];

v1.0.0 is the first stable release: MIT licensed, Symfony component constraints are now declared, and the bundle is wired through a standard PrecognitionBundle with the fluent config API. Check composer why-not output — if you're on Symfony < 6.4 the constraint will now refuse the install rather than fail at runtime.

2. Know the two hard rules of 1.0

  1. Opt-in is mandatory. The release explicitly requires precognitive route opt-in. There is no ambient "any request with the header skips the controller" mode anymore. If an endpoint isn't marked, a Precognition: true request executes your controller normally.
  2. There are two front doors. A request/DTO attribute (the request + header primitives) for #[MapRequestPayload]/#[MapQueryString] controllers, and #[PrecognitiveForm] for Symfony Form controllers. Pick per endpoint; don't mix on one action.

3. Understand the contract in 30 seconds

The bundle registers three cooperating listeners:

  • a short-circuit listener that resolves controller arguments and skips the controller when the request is precognitive;
  • a validate-only listener on kernel.exception that filters violations down to the fields you asked about;
  • a response listener that tags precognitive responses, so you (and your tests) can tell a precognitive 422 from a real one.

Net behaviour: precognitive + invalid → 422 with the violation list, controller never runs; precognitive + valid → 204; not precognitive → your controller runs as normal.

4. First use case — a signup form

#[Route('/api/signup', methods: ['POST'])]
public function signup(
    #[Precognitive]          // confirm exact class name in the README
    #[MapRequestPayload] SignupInput $input,
): JsonResponse {
    $this->users->register($input);   // real requests only
    return $this->json(['ok' => true], 201);
}
final class SignupInput
{
    #[Assert\NotBlank] #[Assert\Email] public string $email = '';
    #[Assert\NotBlank] #[Assert\Length(min: 12)] public string $password = '';
}
const res = await fetch('/api/signup', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', [HEADER]: 'true' },
  body: JSON.stringify(values),
});
// 204 -> valid, 422 -> { errors: { email: [...] }, ... }

5. Where to look first

  • README.md — the exact attribute namespace, the header name constant, and the response tag name. The primitives were extracted in this release, so names may differ from what you remember from 0.x.
  • src/EventListener/ — three small classes now (short-circuit, validate-only, response). Read all three; together they're ~15 minutes and they define the whole contract.
  • PrecognitionBundle / its Configuration — the fluent config gives you the header name and status codes; everything else is convention.
  • bin/console debug:event-dispatcher kernel.controller kernel.exception kernel.response and debug:container --tag=controller.argument_value_resolver.

Implementation Patterns

Single DTO as the source of truth. One input DTO with constraints, used by both the precognitive call and the real submission. The controller stays thin and there's no second validation path to drift. Don't hand-validate a Request array inside the action — that reinstates exactly the duplication precognition removes.

Opt-in per route, always. Since 1.0 requires it, treat the attribute as part of the route's public contract. Put it only on endpoints you've designed for live validation; an endpoint that mutates money, sends mail, or writes an audit row on every validation attempt doesn't belong in the set (or needs rate limiting — see below).

Symfony Forms: use #[PrecognitiveForm]. This is the headline feature of 1.0 for traditional apps. Your controller keeps $form->handleRequest($request) and $form->isValid(), and the bundle intercepts the submit, runs form validation with your existing constraints and constraint/validation_groups options, and returns the violations — no form handling, no persistence:

#[Route('/profile', methods: ['POST'])]
public function edit(#[PrecognitiveForm] Request $request): Response
{
    $form = $this->createForm(ProfileType::class);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $this->profiles->save($form->getData());   // real requests only
        return $this->redirectToRoute('profile');
    }
    return $this->render('profile/edit.html.twig', ['form' => $form]);
}

Keep CSRF enabled on the form: precognitive submits traverse the same firewall and CSRF listener, so your JS must send the token on the precognitive POST too. (This is why a 419 can masquerade as a validation failure — see Gotchas.)

Validate-only: filter violations to the fields the user touched. The validate-only listener plus the violation path filter lets you precognise part of a form, which is what makes per-field blur validation cheap:

await fetch('/api/profile', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', [HEADER]: 'true' },
  body: JSON.stringify({ ...values, [ONLY]: ['email', 'address.street'] }),
});

The filter matches on violation paths, so the strings you send must be exactly the paths your validator/form produces. Keep them in one constant map shared between PHP and JS rather than stringly-typing them at each call site.

Multi-step wizards via validation groups. Groups map naturally to steps; expose one URL per step (or a step parameter the DTO reads) so step 1 can be precognised while the user is still on it:

#[Route('/onboarding/step-2', methods: ['POST'])]
public function stepTwo(#[Precognitive(groups: ['step2'])] #[MapRequestPayload] OnboardingInput $input) {}

Keep group names as shared constants — they are now doubly load-bearing because they exist on both the attribute and the form type.

Query-string DTOs work too. #[MapQueryString] validation status is preserved in this release (422, not a coerced 400), so filter/search endpoints can precognise as well. Same discipline: opt in explicitly, and expect GET responses to be cached — add Vary on your precognition header or bypass the cache layer for those calls.

One frontend helper, no scattered fetches. Wrap header casing, the only-fields parameter, error-shape parsing, and abort handling in a single utility:

export async function precognise(url, payload, { only, signal } = {}) {
  const res = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', [HEADER]: 'true' },
    body: JSON.stringify(only ? { ...payload, [ONLY]: only } : payload),
    signal,
  });
  if (res.status === 204) return {};
  if (res.status === 422) return (await res.json()).errors ?? {};
  throw new Error(`Unexpected precognition status ${res.status}`);
}

Debounce ~300ms on typing and cancel in flight requests with AbortController. Cheap per call, not free: each one deserializes and validates.

Authorization stays in the controller — verify the ordering. Precognition answers "is this payload well-formed?", not "may this user act?". Voters, #[IsGranted], and firewall rules still belong on the action, but the short-circuit deliberately runs before it. Test explicitly that an unauthenticated precognitive request to a protected endpoint gets 401/403 and never a 422 with field errors.

Testing. WebTestCase, with the release's subrequest fix in mind:

$this->client->request('POST', '/api/signup', server: ['HTTP_PRECOGNITION' => 'true'], content: json_encode(['email' => 'nope']));
$this->assertResponseStatusCodeSame(422);
$this->assertJsonPath('errors.email.0', 'This value is not a valid email address.');

Add two guards: a valid precognitive request returns 204 with no side effects (no row, no mail queued, no dispatched domain event), and — now that the response listener tags precognitive responses — an assertion on the response tag so a dependency bump that breaks the wiring fails loudly instead of quietly falling through to your controller.

CI smoke check. Pin ^1.0 (the version is declared and stable now, so a caret range is defensible), and keep one integration test asserting "header in → 204/422 out" for a representative endpoint. That test is your early warning for header-name or status-code drift.

Gotchas and Tips

Precognition never replaces validation on submit. The short-circuit is a UX optimisation. Your real request must still validate via #[MapRequestPayload], $form->isValid(), or an explicit $validator->validate(). Never branch on "the client already precognised this".

Upgrading from 0.x: add the opt-in attribute before you upgrade. The new mandatory route opt-in is the migration step people will miss. After composer update, endpoints that relied on a global header will stop short-circuiting — your controller will execute on precognitive requests and, if it has side effects, you'll find out from data, not from an error. Grep your controllers for the old attribute/header checks first, deploy, then bump.

Validate-only filtering can produce a false 204. If the paths you send don't match the violation paths the validator emitted, the filter removes every violation and you return "valid" for a payload that isn't. This is the highest-risk part of the release. Symptoms: a field that fails on submit always looks fine during typing. Mitigation: derive the field list from the same source as the DTO/form (loop over FormInterface children or DTO properties), log mismatched paths in dev, and keep an integration test per filtered field. Watch the shape mismatch specifically — Validator emits address.street and items[0].name, while forms use child-name paths; your JS must speak the dialect of whichever entry point it's calling.

Status normalisation is deliberate — assert both sides. "Invalid → 422, valid → 204" is now normalised behaviour rather than incidental. If you customise the error format, do it in one place (a kernel.exception listener that reformats 422s, or a normalizer) and keep the codes intact; your JS helper and your tests both depend on them.

Error shape remains the integration risk. ConstraintViolationList is flat (propertyPath + message); nested DTOs give dotted paths, collections give bracketed indices. Almost every JS form library wants a nested object. Map once, centrally — server-side formatter or client-side propertyPath mapper, not both.

#[Valid] is still required for nested objects and collections. Standard Validator behaviour, but the worst failure mode here: precognition returns 204 for a nested DTO whose constraints were never traversed, and the user hits a server error only on submit. Add a test that submits an invalid nested value precognitively and expects 422.

File uploads sit outside the happy path. MapUploadedFile is supported (this release fixes a PHPStan error against it on Symfony 6.4), but multipart bodies can't be replayed as JSON and files aren't meaningfully "precognised". Validate non-file fields on change; validate the file on selection/submit.

Subrequest validation exceptions are ignored by design. The 1.0 fix means a form rendered or validated inside a fragment/ESI subrequest won't be intercepted. If precognition "works everywhere except the embedded form", that's this. Drive the real page, not the fragment.

CSRF and stateful firewalls. Precognitive requests traverse the CSRF listener on session firewalls. Send the token; otherwise a 419 looks like a validation failure in a naive client and you'll spend an afternoon chasing phantom errors.

CORS. A custom header cross-origin requires it in Access-Control-Allow-Headers, and OPTIONS preflights never carry it — make sure preflight requests are not treated as precognitive. Add the header to nelmio/cors-bundle config.

Rate limiting. An unauthenticated POST /api/signup + precognition header is a free validation oracle (and, on form endpoints, a free form-build). Apply symfony/rate-limiter to opt-in endpoints and decide deliberately whether precognitive requests count — they should.

Reading the body twice. Request::getContent() caches within a request, so deserialize-then-validate is safe. Custom file_get_contents('php://input') is not. Empty DTOs behind a proxy but populated ones locally → look here first.

Debugging checklist when a precognitive request executes the controller:

  1. Is the route opted in, with the attribute class the bundle actually reads (v1.0 renamed/extracted primitives)?
  2. Is the header arriving with the exact casing/name configured? (Profiler → Request → Headers.)
  3. Is the bundle registered for this environment in bundles.php?
  4. Does a higher-priority listener (kernel.request locale/trailing-slash rewrite) replace the request or set a response first?
  5. Is this a subrequest? Validation exceptions there are intentionally ignored.
  6. Is the body multipart or already consumed?

Extension points are few and therefore obvious. Response tag/response listener, header name and status codes via the bundle configuration, and listener priority. For custom error output prefer a kernel.exception listener or a normalizer over patching the bundle — it keeps you upgradeable.

You now have exit options. At 1.0 with declared Symfony constraints, MIT licensing, and three readable listeners, the risk profile is much better than pre-release: you can pin ^1.0, and if the API shifts you can reimplement the behaviour as ~60 lines of your own listeners (short-circuit + validate-only + response tag) rather than unpicking attributes from twenty controllers.

Best-fit verdict. High leverage for form-heavy Symfony apps with any JS frontend (Twig + Stimulus, Inertia, SPA) that want Laravel-Precognition-style live validation with server-side rules as the single source of truth — and now, with #[PrecognitiveForm], for classic Symfony Form apps that previously couldn't use it without rewriting controllers around DTOs. Best value: profile/signup/checkout-style forms with a handful of fields and clear per-field validation. Low value for pure server-rendered full-page POSTs, for endpoints whose only validation is CSRF + a few not-blank checks, and for service-to-service APIs where there is no interactive typing to validate.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console