Bug description
Since v6.15.0 (#14475), the frontend form submission route is throttled via a named limiter:
// routes/web.php
Route::post('forms/{form}', [FormController::class, 'submit'])
->middleware([HandlePrecognitiveRequests::class, 'throttle:statamic.forms'])
->name('forms.submit');
// src/Providers/AuthServiceProvider.php
RateLimiter::for('statamic.forms', function (Request $request) {
return Limit::perMinute(10)->by($request->ip());
});
(Sources: routes/web.php#L39 @ v6.20.1, AuthServiceProvider.php#L192 @ v6.20.1; unchanged on the current 6.x branch. For comparison, routes/web.php @ v6.14.0 has no throttle on this route.)
The problem: the same route ships HandlePrecognitiveRequests, and Statamic's docs (https://statamic.dev/frontend/forms) document live validation via form.validate('field') on each input's change event — the canonical Laravel Precognition pattern. Each isolated form.validate() call fires one precognitive POST to this route, and every one of them counts against the same 10/min budget as the actual submission. (Calls inside the 1500 ms debounce window may coalesce into a leading + trailing request with Precognition-Validate-Only batching the touched fields — but filling a form at a normal pace produces roughly one request per field.)
To be clear, this is not a "huge forms" edge case. Count the requests for an ordinary minimal contact form — salutation (radio), first name, last name, phone, email, message, privacy checkbox:
| Interaction |
Requests |
7 fields filled once at a normal pace (radio and checkbox @change fire too) |
7 |
| Visitor fixes a typo or two that the live validation itself pointed out (its entire purpose) |
+1–2 |
| Actual submission |
+1 |
| Total |
9–10 of 10 |
A single visitor filling a perfectly normal form once arrives at the edge of the budget. Any single additional factor — a third correction, one re-edited field, an abandoned attempt in the same minute, a colleague behind the same office/campus NAT (the limit is per IP), or markup that happens to trigger validation twice per interaction — makes the submission request #11, and it is rejected with 429. Live validation exists precisely so users edit fields repeatedly until they're valid — counting those edits against a submission-sized budget breaks the feature by design, not just at scale.
Throttling the submissions themselves at 10/min is perfectly reasonable — the issue is only that validation traffic, which inherently scales with field count and editing behavior, draws from the same bucket.
Two aggravating factors:
- It's invisible in production by default.
ThrottleRequestsException is an HttpException, which Laravel's default exception handler does not report ($internalDontReport) — nothing reaches laravel.log unless a site customizes reporting. Sites typically only notice via user complaints.
- The 429s on the validation requests themselves surface only as unhandled promise rejections in the browser console — invisible to the visitor, whose live validation simply stops working before the submit even fails.
Laravel's own Precognition docs ("Managing Side-Effects", https://laravel.com/docs/12.x/precognition#managing-side-effects) explicitly recommend that middleware check $request->isPrecognitive() so that precognitive requests are not counted like real interactions. The default limiter does the opposite: it counts a validation ping the same as a submission.
The discussion in #14475 shows awareness of this ("10 per minute for forms (precognitive requests made me up it)"), but a flat counter can't be sized correctly for both traffic types at once — validation volume scales with field count and user behavior, submission volume doesn't.
How to reproduce
- Fresh Statamic ≥ 6.15 site with a form using the documented Alpine/Precognition wiring (
@change="form.validate('handle')" per field) — e.g. any form scaffolded from the docs or Statamic Peak.
- Give the form the fields of a typical contact form: salutation (radio), first name, last name, phone, email, message, privacy consent (checkbox).
- Open DevTools → Network, filter the form endpoint. Fill the form field by field (pause ~2s between fields so the debounce doesn't coalesce requests), correct one or two fields as a real visitor would. Watch
x-ratelimit-remaining drop to 0 from validation requests alone.
- Submit → 429 Too Many Requests on the real submission.
Observed in production on Statamic 6.20.1 / Laravel 13 / PHP 8.4 after upgrading from 6.11 (where the route had no throttle). Nothing in laravel.log; confirmed via response headers (x-ratelimit-limit: 10, x-ratelimit-remaining: 0, retry-after: …).
Logs
Environment
- Statamic: 6.20.1 (Pro)
- Laravel: 13.x
- PHP: 8.4
- Install: existing site, upgraded 6.11 → 6.20.1
- Antlers frontend, forms wired per the docs / Statamic Peak (laravel-precognition-alpine ^0.7)
Installation
Fresh statamic/statamic site via CLI
Additional details
Differentiate precognitive requests in the default limiter, e.g.:
RateLimiter::for('statamic.forms', function (Request $request) {
return $request->isPrecognitive()
? Limit::perMinute(60)->by('precognition:'.$request->ip())
: Limit::perMinute(10)->by('submission:'.$request->ip());
});
Note the distinct by() keys: Laravel builds the throttle cache key from the limiter name plus the limit's key — returning different limits with the same by($request->ip()) would still share a single counter, so validation traffic would keep eating the submission budget.
…and a short note in the 6.x upgrade guide that frontend form submissions are now rate limited and how to customize the limiter — for sites using live validation this is a behavioral change that currently arrives silently in a minor release.
Workaround (for anyone else hitting this)
Override the named limiter in a service provider's boot() (application providers boot after the package provider, so the app definition wins — also confirmed in the #14475 discussion):
RateLimiter::for('statamic.forms', function (Request $request) {
return $request->isPrecognitive()
? Limit::perMinute(120)->by('precognition:'.$request->ip())
: Limit::perMinute(20)->by('submission:'.$request->ip());
});
(Distinct by() keys are required — see above.)
Bug description
Since v6.15.0 (#14475), the frontend form submission route is throttled via a named limiter:
(Sources: routes/web.php#L39 @ v6.20.1, AuthServiceProvider.php#L192 @ v6.20.1; unchanged on the current 6.x branch. For comparison, routes/web.php @ v6.14.0 has no throttle on this route.)
The problem: the same route ships
HandlePrecognitiveRequests, and Statamic's docs (https://statamic.dev/frontend/forms) document live validation viaform.validate('field')on each input'schangeevent — the canonical Laravel Precognition pattern. Each isolatedform.validate()call fires one precognitive POST to this route, and every one of them counts against the same 10/min budget as the actual submission. (Calls inside the 1500 ms debounce window may coalesce into a leading + trailing request withPrecognition-Validate-Onlybatching the touched fields — but filling a form at a normal pace produces roughly one request per field.)To be clear, this is not a "huge forms" edge case. Count the requests for an ordinary minimal contact form — salutation (radio), first name, last name, phone, email, message, privacy checkbox:
@changefire too)A single visitor filling a perfectly normal form once arrives at the edge of the budget. Any single additional factor — a third correction, one re-edited field, an abandoned attempt in the same minute, a colleague behind the same office/campus NAT (the limit is per IP), or markup that happens to trigger validation twice per interaction — makes the submission request #11, and it is rejected with 429. Live validation exists precisely so users edit fields repeatedly until they're valid — counting those edits against a submission-sized budget breaks the feature by design, not just at scale.
Throttling the submissions themselves at 10/min is perfectly reasonable — the issue is only that validation traffic, which inherently scales with field count and editing behavior, draws from the same bucket.
Two aggravating factors:
ThrottleRequestsExceptionis anHttpException, which Laravel's default exception handler does not report ($internalDontReport) — nothing reacheslaravel.logunless a site customizes reporting. Sites typically only notice via user complaints.Laravel's own Precognition docs ("Managing Side-Effects", https://laravel.com/docs/12.x/precognition#managing-side-effects) explicitly recommend that middleware check
$request->isPrecognitive()so that precognitive requests are not counted like real interactions. The default limiter does the opposite: it counts a validation ping the same as a submission.The discussion in #14475 shows awareness of this ("10 per minute for forms (precognitive requests made me up it)"), but a flat counter can't be sized correctly for both traffic types at once — validation volume scales with field count and user behavior, submission volume doesn't.
How to reproduce
@change="form.validate('handle')"per field) — e.g. any form scaffolded from the docs or Statamic Peak.x-ratelimit-remainingdrop to 0 from validation requests alone.Observed in production on Statamic 6.20.1 / Laravel 13 / PHP 8.4 after upgrading from 6.11 (where the route had no throttle). Nothing in
laravel.log; confirmed via response headers (x-ratelimit-limit: 10,x-ratelimit-remaining: 0,retry-after: …).Logs
Environment
Installation
Fresh statamic/statamic site via CLI
Additional details
Differentiate precognitive requests in the default limiter, e.g.:
Note the distinct
by()keys: Laravel builds the throttle cache key from the limiter name plus the limit's key — returning different limits with the sameby($request->ip())would still share a single counter, so validation traffic would keep eating the submission budget.…and a short note in the 6.x upgrade guide that frontend form submissions are now rate limited and how to customize the limiter — for sites using live validation this is a behavioral change that currently arrives silently in a minor release.
Workaround (for anyone else hitting this)
Override the named limiter in a service provider's
boot()(application providers boot after the package provider, so the app definition wins — also confirmed in the #14475 discussion):(Distinct
by()keys are required — see above.)