From 29b5aee81c72d5164c8e35625b2ca576d4db472f Mon Sep 17 00:00:00 2001 From: Gareth Trinkwon Date: Mon, 19 Oct 2026 12:25:23 +0100 Subject: [PATCH 1/3] Integrated Recaptcha V3 package into accessible-forms so that it can be included in form builder. --- composer.json | 3 + docs/README.md | 3 +- docs/recaptcha.md | 67 +++++++++++++++++++ src/FieldTypes/RecaptchaV3.php | 58 ++++++++++++++++ .../views/Form/accessible-forms.html.twig | 52 ++++++++++++++ src/Validation/RecaptchaV3Rule.php | 57 ++++++++++++++++ 6 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 docs/recaptcha.md create mode 100644 src/FieldTypes/RecaptchaV3.php create mode 100644 src/Validation/RecaptchaV3Rule.php diff --git a/composer.json b/composer.json index 91e8def..c00fefe 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,9 @@ "php": "^8.2", "barryvdh/laravel-form-bridge": "^0.7.4" }, + "suggest": { + "josiasmontag/laravel-recaptchav3": "Required to use the RecaptchaV3 field type (Laravel only)" + }, "require-dev": { "phpstan/phpstan": "^2.1", "phpunit/phpunit": ">10", diff --git a/docs/README.md b/docs/README.md index afe62d5..e00471d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,17 +34,16 @@ To notify a user of an error with their form submission: Symfony Form will automatically display an error message next to the input field. Error messages should have a unique ID and be associated with the form input via the `aria-describedby` attribute. This is provided in the accessible form theme. - ## Docs - [Using accessible forms with Laravel](laravel.md) - [Using accessible forms with Symfony](symfony.md) -- [Form options](form-options.md) - Form helpers to create a form - Accessible form theme - GOV.UK Design System form theme - [Indicate form errors in the page title](page-title.md) - [Error summary](error-summary.md) +- [reCAPTCHA v3 field](recaptcha.md) - Every input field needs a label - Indicate required fields - Use field hints instead of placeholders diff --git a/docs/recaptcha.md b/docs/recaptcha.md new file mode 100644 index 0000000..015f950 --- /dev/null +++ b/docs/recaptcha.md @@ -0,0 +1,67 @@ +# reCAPTCHA v3 field + +The `RecaptchaV3` field type adds invisible [Google reCAPTCHA v3](https://developers.google.com/recaptcha/docs/v3) spam protection to a form. +It is currently Laravel only. + +reCAPTCHA v3 does not ask the user to solve a challenge, so it adds no extra steps for keyboard, screen reader or other assistive technology users. + +The token is requested when the form is submitted rather than when the page loads. reCAPTCHA tokens expire after 2 minutes, so fetching +one on page load would cause the form to fail for anyone who takes longer than that to complete it. + +## Installation + +Install [laravel-recaptchav3](https://github.com/josiasmontag/laravel-recaptchav3) via [Composer](https://getcomposer.org/): + +```shell +composer require josiasmontag/laravel-recaptchav3 +``` + +Add your keys to `.env`: + +``` +RECAPTCHAV3_SITEKEY=sitekey +RECAPTCHAV3_SECRET=secret +``` + +Load the reCAPTCHA script on any page that uses the field, for example in your layout template: + +```twig +{{ recaptchaJs()|raw }} +``` + +Where `recaptchaJs()` is a Twig function that returns `RecaptchaV3::initJs()`. + +## Usage + +Add the field to your form class, usually just before the submit button: + +```php +use Studio24\AccessibleForms\FieldTypes\RecaptchaV3; + +$builder + ->add('recaptcha', RecaptchaV3::class, [ + 'action' => 'contact', + ]) + ->add('save', SubmitType::class, ['label' => 'Send']); +``` + +The token is validated when the form is submitted via `$form->isValid()`. No extra controller code is needed. + +## Options + +| Option | Default | Description | +|-----------|------------|-------------------------------------------------------------------------------------------------------| +| `action` | `submit` | reCAPTCHA action name, shown in the reCAPTCHA admin console. Letters, numbers, `/` and `_` only. | +| `score` | `0.5` | Minimum score (0.0 to 1.0) needed to pass validation. | +| `message` | See below | Error message shown in the error summary and above the field if verification fails. | + +The default error message is "We could not confirm this form was sent by a person. Please try submitting the form again." + +## Accessibility notes + +- If verification fails, the error is shown in the [error summary](error-summary.md). The summary link moves focus to the error message on the page. +- If the reCAPTCHA script cannot load (for example, it is blocked), the form still submits and the user sees the error message rather than a form that does nothing. +- Google requires you to tell users that the site is protected by reCAPTCHA if you hide the badge. See [hiding the badge](https://developers.google.com/recaptcha/docs/faq#id-like-to-hide-the-recaptcha-badge.-what-is-allowed). + +--- +Back to [Docs index](README.md) diff --git a/src/FieldTypes/RecaptchaV3.php b/src/FieldTypes/RecaptchaV3.php new file mode 100644 index 0000000..a867566 --- /dev/null +++ b/src/FieldTypes/RecaptchaV3.php @@ -0,0 +1,58 @@ +setDefaults([ + 'mapped' => false, + 'required' => false, + 'label' => false, + 'error_bubbling' => false, + 'action' => 'submit', + 'score' => 0.5, + 'message' => 'We could not confirm this form was sent by a person. Please try submitting the form again.', + 'rules' => function (Options $options) { + return [new RecaptchaV3Rule($options['action'], (float) $options['score'], $options['message'])]; + }, + ]); + + // Google only allows alphanumerics, slashes and underscores in action names + $resolver->setAllowedTypes('action', 'string'); + $resolver->setAllowedValues('action', fn (string $action) => preg_match('/^[A-Za-z0-9_\/]+$/', $action) === 1); + $resolver->setAllowedTypes('score', ['int', 'float']); + $resolver->setAllowedTypes('message', 'string'); + } + + public function buildView(FormView $view, FormInterface $form, array $options) + { + $view->vars['action'] = $options['action']; + $view->vars['sitekey'] = config('recaptchav3.sitekey'); + } + + public function getParent(): string + { + return HiddenType::class; + } + + public function getBlockPrefix(): string + { + return 'recaptcha_v3'; + } +} diff --git a/src/Resources/views/Form/accessible-forms.html.twig b/src/Resources/views/Form/accessible-forms.html.twig index 91fd126..e274dc7 100644 --- a/src/Resources/views/Form/accessible-forms.html.twig +++ b/src/Resources/views/Form/accessible-forms.html.twig @@ -246,4 +246,56 @@ {{ html }} {%- endblock -%} +{# reCAPTCHA v3: requires the reCAPTCHA api.js script on the page. + The wrapper takes the field ID so error summary links move focus to the visible error message. + The token is fetched on submit so it cannot expire (after 2 minutes) while the form is being completed. #} +{%- block recaptcha_v3_row -%} +
0 %} class="tbxforms-form-group tbxforms-form-group--error"{% endif %}> + {{- form_errors(form) -}} + +
+ +{%- endblock recaptcha_v3_row -%} + diff --git a/src/Validation/RecaptchaV3Rule.php b/src/Validation/RecaptchaV3Rule.php new file mode 100644 index 0000000..2268711 --- /dev/null +++ b/src/Validation/RecaptchaV3Rule.php @@ -0,0 +1,57 @@ +message); + return; + } + + try { + $score = app(RecaptchaV3::class)->verify($value, $this->action); + } catch (Throwable $e) { + report($e); + $score = false; + } + + if ($score === false || $score < $this->score) { + $fail($this->message); + } + } + + /** + * laravel-form-bridge's RulesParser treats every rule as a string when building the view + */ + public function __toString(): string + { + return 'recaptcha_v3'; + } +} From ebc7e0c8a053f52b88ab5e783d706abd644a4c48 Mon Sep 17 00:00:00 2001 From: Gareth Trinkwon Date: Mon, 19 Oct 2026 12:25:23 +0100 Subject: [PATCH 2/3] Integrated Recaptcha V3 package into accessible-forms so that it can be included in form builder. --- composer.json | 3 + docs/README.md | 3 +- docs/recaptcha.md | 67 +++++++++++++++++++ src/FieldTypes/RecaptchaV3.php | 58 ++++++++++++++++ .../views/Form/accessible-forms.html.twig | 52 ++++++++++++++ src/Validation/RecaptchaV3Rule.php | 57 ++++++++++++++++ 6 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 docs/recaptcha.md create mode 100644 src/FieldTypes/RecaptchaV3.php create mode 100644 src/Validation/RecaptchaV3Rule.php diff --git a/composer.json b/composer.json index 91e8def..c00fefe 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,9 @@ "php": "^8.2", "barryvdh/laravel-form-bridge": "^0.7.4" }, + "suggest": { + "josiasmontag/laravel-recaptchav3": "Required to use the RecaptchaV3 field type (Laravel only)" + }, "require-dev": { "phpstan/phpstan": "^2.1", "phpunit/phpunit": ">10", diff --git a/docs/README.md b/docs/README.md index afe62d5..e00471d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,17 +34,16 @@ To notify a user of an error with their form submission: Symfony Form will automatically display an error message next to the input field. Error messages should have a unique ID and be associated with the form input via the `aria-describedby` attribute. This is provided in the accessible form theme. - ## Docs - [Using accessible forms with Laravel](laravel.md) - [Using accessible forms with Symfony](symfony.md) -- [Form options](form-options.md) - Form helpers to create a form - Accessible form theme - GOV.UK Design System form theme - [Indicate form errors in the page title](page-title.md) - [Error summary](error-summary.md) +- [reCAPTCHA v3 field](recaptcha.md) - Every input field needs a label - Indicate required fields - Use field hints instead of placeholders diff --git a/docs/recaptcha.md b/docs/recaptcha.md new file mode 100644 index 0000000..015f950 --- /dev/null +++ b/docs/recaptcha.md @@ -0,0 +1,67 @@ +# reCAPTCHA v3 field + +The `RecaptchaV3` field type adds invisible [Google reCAPTCHA v3](https://developers.google.com/recaptcha/docs/v3) spam protection to a form. +It is currently Laravel only. + +reCAPTCHA v3 does not ask the user to solve a challenge, so it adds no extra steps for keyboard, screen reader or other assistive technology users. + +The token is requested when the form is submitted rather than when the page loads. reCAPTCHA tokens expire after 2 minutes, so fetching +one on page load would cause the form to fail for anyone who takes longer than that to complete it. + +## Installation + +Install [laravel-recaptchav3](https://github.com/josiasmontag/laravel-recaptchav3) via [Composer](https://getcomposer.org/): + +```shell +composer require josiasmontag/laravel-recaptchav3 +``` + +Add your keys to `.env`: + +``` +RECAPTCHAV3_SITEKEY=sitekey +RECAPTCHAV3_SECRET=secret +``` + +Load the reCAPTCHA script on any page that uses the field, for example in your layout template: + +```twig +{{ recaptchaJs()|raw }} +``` + +Where `recaptchaJs()` is a Twig function that returns `RecaptchaV3::initJs()`. + +## Usage + +Add the field to your form class, usually just before the submit button: + +```php +use Studio24\AccessibleForms\FieldTypes\RecaptchaV3; + +$builder + ->add('recaptcha', RecaptchaV3::class, [ + 'action' => 'contact', + ]) + ->add('save', SubmitType::class, ['label' => 'Send']); +``` + +The token is validated when the form is submitted via `$form->isValid()`. No extra controller code is needed. + +## Options + +| Option | Default | Description | +|-----------|------------|-------------------------------------------------------------------------------------------------------| +| `action` | `submit` | reCAPTCHA action name, shown in the reCAPTCHA admin console. Letters, numbers, `/` and `_` only. | +| `score` | `0.5` | Minimum score (0.0 to 1.0) needed to pass validation. | +| `message` | See below | Error message shown in the error summary and above the field if verification fails. | + +The default error message is "We could not confirm this form was sent by a person. Please try submitting the form again." + +## Accessibility notes + +- If verification fails, the error is shown in the [error summary](error-summary.md). The summary link moves focus to the error message on the page. +- If the reCAPTCHA script cannot load (for example, it is blocked), the form still submits and the user sees the error message rather than a form that does nothing. +- Google requires you to tell users that the site is protected by reCAPTCHA if you hide the badge. See [hiding the badge](https://developers.google.com/recaptcha/docs/faq#id-like-to-hide-the-recaptcha-badge.-what-is-allowed). + +--- +Back to [Docs index](README.md) diff --git a/src/FieldTypes/RecaptchaV3.php b/src/FieldTypes/RecaptchaV3.php new file mode 100644 index 0000000..a867566 --- /dev/null +++ b/src/FieldTypes/RecaptchaV3.php @@ -0,0 +1,58 @@ +setDefaults([ + 'mapped' => false, + 'required' => false, + 'label' => false, + 'error_bubbling' => false, + 'action' => 'submit', + 'score' => 0.5, + 'message' => 'We could not confirm this form was sent by a person. Please try submitting the form again.', + 'rules' => function (Options $options) { + return [new RecaptchaV3Rule($options['action'], (float) $options['score'], $options['message'])]; + }, + ]); + + // Google only allows alphanumerics, slashes and underscores in action names + $resolver->setAllowedTypes('action', 'string'); + $resolver->setAllowedValues('action', fn (string $action) => preg_match('/^[A-Za-z0-9_\/]+$/', $action) === 1); + $resolver->setAllowedTypes('score', ['int', 'float']); + $resolver->setAllowedTypes('message', 'string'); + } + + public function buildView(FormView $view, FormInterface $form, array $options) + { + $view->vars['action'] = $options['action']; + $view->vars['sitekey'] = config('recaptchav3.sitekey'); + } + + public function getParent(): string + { + return HiddenType::class; + } + + public function getBlockPrefix(): string + { + return 'recaptcha_v3'; + } +} diff --git a/src/Resources/views/Form/accessible-forms.html.twig b/src/Resources/views/Form/accessible-forms.html.twig index 91fd126..e274dc7 100644 --- a/src/Resources/views/Form/accessible-forms.html.twig +++ b/src/Resources/views/Form/accessible-forms.html.twig @@ -246,4 +246,56 @@ {{ html }} {%- endblock -%} +{# reCAPTCHA v3: requires the reCAPTCHA api.js script on the page. + The wrapper takes the field ID so error summary links move focus to the visible error message. + The token is fetched on submit so it cannot expire (after 2 minutes) while the form is being completed. #} +{%- block recaptcha_v3_row -%} +
0 %} class="tbxforms-form-group tbxforms-form-group--error"{% endif %}> + {{- form_errors(form) -}} + +
+ +{%- endblock recaptcha_v3_row -%} + diff --git a/src/Validation/RecaptchaV3Rule.php b/src/Validation/RecaptchaV3Rule.php new file mode 100644 index 0000000..2268711 --- /dev/null +++ b/src/Validation/RecaptchaV3Rule.php @@ -0,0 +1,57 @@ +message); + return; + } + + try { + $score = app(RecaptchaV3::class)->verify($value, $this->action); + } catch (Throwable $e) { + report($e); + $score = false; + } + + if ($score === false || $score < $this->score) { + $fail($this->message); + } + } + + /** + * laravel-form-bridge's RulesParser treats every rule as a string when building the view + */ + public function __toString(): string + { + return 'recaptcha_v3'; + } +} From 765c20aa0e0fe2c151a07adbb011924f0f8bea96 Mon Sep 17 00:00:00 2001 From: Gareth Trinkwon Date: Mon, 19 Oct 2026 14:34:15 +0100 Subject: [PATCH 3/3] feat: Added optional nonce parameter to inline javascript for CSP support. --- docs/recaptcha.md | 12 ++++++++++++ src/FieldTypes/RecaptchaV3.php | 3 +++ src/Resources/views/Form/accessible-forms.html.twig | 2 +- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/recaptcha.md b/docs/recaptcha.md index 015f950..e88a21e 100644 --- a/docs/recaptcha.md +++ b/docs/recaptcha.md @@ -54,9 +54,21 @@ The token is validated when the form is submitted via `$form->isValid()`. No ext | `action` | `submit` | reCAPTCHA action name, shown in the reCAPTCHA admin console. Letters, numbers, `/` and `_` only. | | `score` | `0.5` | Minimum score (0.0 to 1.0) needed to pass validation. | | `message` | See below | Error message shown in the error summary and above the field if verification fails. | +| `nonce` | `null` | Content Security Policy nonce added to the inline `