Repository navigation
[6.x] Forms 2: Add captcha support to forms - #15862
Open
DanielDarrenJones wants to merge 4 commits into
Open
DanielDarrenJones wants to merge 4 commits into
DanielDarrenJones wants to merge 4 commits into
Conversation
Adds a CaptchaManager with turnstile, hcaptcha, recaptcha (v2) and
recaptcha_v3 drivers, configured in config/forms.php and extendable with
Captcha::extend(). Forms opt in from their settings, and failures are
either refused with a validation error or saved as spam, mirroring the
honeypot. The form tag renders the widget automatically on the last page,
or where {{ captcha }} / {{ form:captcha }} places it.
…S driver captcha data
…atamic#15851) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Statamic forms have no captcha support. The only built-in spam protection is the honeypot, which stops simple bots but not ones that leave hidden fields empty or run a real browser. So people end up using addons or wiring up Turnstile, hCaptcha or reCAPTCHA themselves: rendering the widget, then listening to
FormSubmittedto check the token. Each addon ends up doing this its own way, and none of them can use forms-2's spam status the way the honeypot does.This PR adds captchas to core, driver based, so one is set up once in config and turned on per form. The same driver API can be used by addons that need a captcha on their own forms.
Setting it up
Built-in drivers are
turnstile,hcaptcha,recaptcha(v2 checkbox),recaptcha_v3andnull. Nothing happens until both keys are set.Each form then gets two new settings next to the honeypot ones:
captcha. Save as Spam stores it as spam for review, the same way the honeypot's "Save as Spam" does. A form that doesn't store submissions always shows an error.Rendering
The form tag puts the widget at the end of the form automatically. To place it yourself, use
{{ captcha }}(or{!! $captcha !!}in Blade components), or{{ form:captcha }}inside the form or{{ form:captcha in="contact" }}outside it. The tag sees the widget'sdata-captchaattribute and doesn't add it a second time.On multi-page forms the widget only appears on the last page, since that's where it's checked.
Verification
SubmitForm::submit()checks the captcha when the submission is about to be finalized, after field validation and the honeypot and beforeFormSubmittedis dispatched. Earlier pages of a multi-page form and precognitive validation requests are never checked.Verification uses Laravel's HTTP client with a timeout. A provider that can't be reached, an error response, or anything other than
"success": truecounts as a failure. Missing tokens, tokens that aren't strings, and tokens over 8192 characters fail without making a request. reCAPTCHA v3 also checks the score and that the token was made for the configured action. The token is never stored on the submission.If a resumed partial submission fails on its last page, the partial is kept so the visitor can try again.
Custom drivers
A driver implements
Statamic\Contracts\Forms\Captcha:Providers that use a "siteverify" endpoint can extend
Statamic\Forms\Captcha\SiteverifyCaptchaand only supply the endpoint, field name and widget. Custom drivers count as set up as soon as they're the configured driver, since they may not use a site key and secret.Addons can use the same thing for their own forms:
Captcha::enabled(),Captcha::driver()->widget(), andCaptcha::driver()->verify($token, $request->ip()).Tests
tests/Forms/Captcha/*(manager, drivers viaHttp::fake,SubmitForm),tests/Tags/Form/FormCaptchaTest.php, and a CP update test intests/Feature/Forms/UpdateFormTest.php.Docs
Docs PR to follow for statamic.dev (forms → spam protection), covering the config, the per-form settings, the tag/variable, and writing a driver.
Open questions
Formsandconfig/forms.php. If user registration, password reset or addons should use it too,Statamic\Captchaand its ownconfig/captcha.php(orsystem.php) may fit better. Right now the user registration form only has a honeypot.Managerpattern supports named configurations (likeprotect.schemes) if different forms should use different providers or keys. Is that worth the extra config?captcha, not the provider's field (cf-turnstile-response), so templates can use{{ error:captcha }}with any driver. This clashes if a blueprint has a field with the handlecaptcha. Reserve the handle, or use something like_captcha?$form.submit()sends only the Alpine data, not the widget's hidden input. Something has to copy the token into the form data (for example a Turnstiledata-callback). Should the Alpine Precognition driver do that itself, or should it be documented?submit). Using the form handle would stop a token made for one form being replayed on another, but that would need the form passed towidget()/verify(), which makes the contract bigger. Worth it?{{ form:captcha }}stops a form with the handlecaptchabeing used as{{ form:captcha }}.fields,errors,successand the other tags already have the same problem, but it's one more.async defer. Sites with a strict CSP need the provider's domains allowed. Should there be a nonce hook, or a way to output the script separately (for example{{ form:captcha_script }})?fail_openoption?Reviewed with sol-loop: 2 rounds with gpt-5.6-sol on high. Sol verdict: approved
Created with Claude Opus 5.5 in Claude Code.