Skip to content

Default statamic.forms rate limiter conflicts with Precognition live validation — Simple Contact Forms are broken. #14807

Description

@paul-hph

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:

  1. 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.
  2. 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

  1. 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.
  2. Give the form the fields of a typical contact form: salutation (radio), first name, last name, phone, email, message, privacy consent (checkbox).
  3. 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.
  4. 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.)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions