gjuric/recaptcha-bundle
gjuric/recaptcha-bundle integrates Google reCAPTCHA into Symfony forms with a dedicated form type, server-side validation, and configurable keys. It supports reCAPTCHA v2 and v3, handles verification responses, and lets you protect forms and endpoints from spam and bots with minimal configuration.
What changed in v1.6.2: one line — "Fixes Symfony 8.0 deprecations." No new features, no behavioral changes, no config-key changes. It is a compatibility maintenance cut, which is useful information (the fork is alive and tracking Symfony 8) but changes nothing about day-to-day usage, and nothing about the Laravel story below.
First, the honest framing: this is a Symfony bundle, not a Laravel package. There is no service provider, no facade, no config/recaptcha.php. There is still no illuminate/* dependency in v1.6.2. It hooks into symfony/form, symfony/http-kernel, Twig form themes, and the DI container. From a Laravel codebase you cannot composer require it and get value — you can read it, port ~80 lines of it, or copy the widget markup. Treat the assessment below as "what this bundle does, and what I'd rebuild in Laravel."
Minimal Symfony steps (if you're in the target framework):
composer require gjuric/recaptcha-bundle
// config/bundles.php
EWZ\Bundle\RecaptchaBundle\EWZRecaptchaBundle::class => ['all' => true],
# config/packages/ewz_recaptcha.yaml
ewz_recaptcha:
public_key: '%env(EWZ_RECAPTCHA_PUBLIC_KEY)%'
private_key: '%env(EWZ_RECAPTCHA_PRIVATE_KEY)%'
enabled: true
Where to look first, in this order:
README.md — the options table is the real documentation; the API surface is tiny.DependencyInjection/Configuration.php — the authoritative list of config keys and defaults. Don't trust blog posts; forks drift.composer.json — v1.6.2's whole point is a Symfony 8 constraint widening. Read the require block before adopting: Symfony 8 means PHP 8.4, so if you're pinned to Symfony 7 / PHP 8.2–8.3 you likely need the previous tag, not this one.Form/Type/RecaptchaType.php — what options the field accepts (ajax, hide_badge, locale, attr...).Validator/Constraints/IsTrue.php (+ its validator) — the actual server-side verify call. This is the file you'd port.Resources/views/Form/*.html.twig — the widget block; you'll override this constantly for Bootstrap/Tailwind.Service/ReCaptcha*.php — the HTTP client against siteverify; check proxy/timeout handling.First use case — a contact form:
use EWZ\Bundle\RecaptchaBundle\Form\Type\RecaptchaType;
use EWZ\Bundle\RecaptchaBundle\Validator\Constraints\IsTrue as RecaptchaTrue;
$builder
->add('name')
->add('email')
->add('message', TextareaType::class)
->add('recaptcha', RecaptchaType::class, [
'mapped' => false,
'constraints' => [new RecaptchaTrue()],
'attr' => ['options' => ['theme' => 'light']],
]);
{{ form_start(form) }}
{{ form_widget(form) }}
<button>Send</button>
{{ form_end(form) }}
That's it — the widget block emits the g-recaptcha div and the Google script tag. If you get Block "recaptcha_widget" does not exist, register the bundled theme (see Gotchas).
Laravel translation of the same thing (what you'd actually ship — unchanged by this release):
// app/Rules/Recaptcha.php
public function passes($attribute, $value): bool
{
if (! config('services.recaptcha.enabled')) {
return true; // mirrors the bundle's `enabled: false`
}
$r = Http::asForm()->timeout(5)->post('https://www.google.com/recaptcha/api/siteverify', [
'secret' => config('services.recaptcha.secret'),
'response' => $value,
'remoteip' => request()->ip(),
]);
if (! $r->ok() || ! $r->json('success')) {
return false;
}
// v3 only: score gate
$threshold = config('services.recaptcha.score', 0.5);
return $r->json('score', 1.0) >= $threshold;
}
Plus a Blade partial that renders <div class="g-recaptcha" data-sitekey="..."> and @once @push('scripts') <script src="https://www.google.com/recaptcha/api.js" async defer></script> @endpush @endonce. That is 95% of what the bundle gives you, minus the Symfony plumbing.
Nothing in this section changed in v1.6.2. Deprecation fixes are internal (attribute/return-type/injection-style churn against Symfony 8 internals); the form type options, config keys, and Twig blocks are the same. Existing patterns below are still the whole playbook.
Invisible vs. checkbox vs. score (v2 vs v3). The bundle supports both, but the mental model differs: v2 returns a boolean you assert with IsTrue; v3 returns a score and needs a score_threshold in config plus an action you should be asserting server-side. If you're on v3 and only wiring the constraint, you're accepting every score — including 0.1. The constraint alone is not enough; check the score.
enabled: false is the testing story. This is the single most useful knob day-to-day:
# config/packages/test/ewz_recaptcha.yaml
ewz_recaptcha:
public_key: 'test'
private_key: 'test'
enabled: false
With it off, the field still renders (so form definitions and form_widget calls stay valid) but the validator short-circuits and no network call happens. Functional tests that submit forms keep passing in CI with no Google dependency and no flake. Do the same in config/packages/dev/ unless you're specifically exercising the real flow. Note: if you upgrade to v1.6.2 to get Symfony 8 you'll probably also bump your Symfony test kernel, so re-run the suite once even though the bundle's behavior looks identical.
Keep the field unmapped. 'mapped' => false on every usage. The g-recaptcha-response payload is not part of your entity/DTO, and mapping it will silently try to hydrate a property that doesn't exist.
Ajax submission. The bundle's ajax option is designed for forms submitted without a page reload — it wires the token into an XHR submit path and returns a JSON error for the front-end to render. Worth using if you're on Symfony UX/Turbo/Live Components, because re-rendering a form fragment invalidates the token and you must re-run grecaptcha.render() (or grecaptcha.reset()) after the swap. If you're doing partial re-renders, plan the reset explicitly — the bundle won't guess.
Multiple captchas on one page. Each RecaptchaType field gets its own container; the script tag must only be emitted once. If you override the theme block, guard the <script> include with a "already emitted" flag (the bundle's own block does this). Duplicated api.js loads cause the "reCAPTCHA has already been rendered in this element" error.
Theming. Override recaptcha_widget in your own form theme rather than editing vendor files:
{% block recaptcha_widget %}
<div class="g-recaptcha"
data-sitekey="{{ public_key }}"
{% if attr.options is defined %}data-theme="{{ attr.options.theme|default('light') }}"{% endif %}>
</div>
{% endblock %}
For v3 badges that overlap your sticky footer, hide_badge: true plus your own "protected by reCAPTCHA" text is the standard workaround — but Google's terms require the badge to be visible somewhere, so hide-and-render-elsewhere, not hide-and-forget.
Constraint message & i18n. new RecaptchaTrue(['message' => 'Please confirm you are human.']) — the default message is not translated by Symfony's translator unless you ship the catalogue. Set it explicitly, and set locale in config so the widget itself is localized (defaults to request locale, which is usually what you want).
Wizard/multi-step forms. Verify at the final submit only. Tokens are single-use and expire in ~2 minutes; putting the field on step 1 and the submit on step 3 is a guaranteed failure for anyone who reads slowly.
illuminate/* dependencies, 0 stars and 0 dependents on this fork while upstream ewz-recaptcha-bundle has the docs, issues, and recipe. If you are in Symfony, prefer upstream and pin — unless upstream hasn't shipped its Symfony 8 deprecation pass, in which case this fork's 1.6.2 is currently the differentiator. If you're in Laravel, port it — the siteverify call is the whole product, and this release did not change that call.symfony/* constraints now allow ^8.0, and Symfony 8 requires PHP 8.4. Check composer.json before running composer update: on PHP 8.2/8.3 you'll stay on the previous tag, and that's fine — you get zero behavioral benefit from the bump.@required/autowire attributes, and return types on internals you may have decorated. If you decorated RecaptchaVerifier or the HTTP client, diff the service definitions and re-run the suite once.Block "recaptcha_widget" does not exist. Almost always a form-theme registration problem. Add '@EWZRecaptcha/Form/ewz_recaptcha_widget.html.twig' to twig.form_themes in config/packages/twig.yaml. This bites everyone using Bootstrap 4/5 or a custom theme.example.com returns success: false with hostname mismatch on localhost and on your staging domain. Generate separate key pairs per environment; don't "fix" it in code.verify_host and proxies. This option makes the bundle compare the response hostname against the current request host. Behind a load balancer, a reverse proxy, or a Heroku-style router, the request host may not match what Google saw — either trust your forwarded headers (trusted_proxies / trusted_hosts) or disable the check. Symptom: works locally, fails only in production.script-src https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/, the iframe needs frame-src https://www.google.com/recaptcha/, and v3 calls back to https://www.google.com/recaptcha/api2/. Without these the widget renders an empty box and no JS error reaches your logs. Check the browser console before you check PHP.g-recaptcha-response. The constraint fails with a generic message. Make sure the form shows a field-level error rather than a 500; the null response path must be handled, and a plain required HTML attribute won't do it.error-codes array when success is false — invalid-input-secret, timeout-or-duplicate, and invalid-input-response mean three very different things and the bundle surfaces none of them by default.timeout-or-duplicate. Disable the submit button on click, and don't retry the verify call.timeout option and set http_proxy if your egress requires it. In Laravel, Http::timeout(5) and a try/catch that fails open or closed deliberately — pick one and document it.enabled: false is also a security footgun. It's the right call in test/dev, but a stray APP_ENV in production silently disables your bot protection with no warning. Log a startup notice when the bundle is disabled outside dev, or assert on it in a health check.recaptcha_widget block → decorate the verifier service to add logging/metrics → swap the constraint's message → replace the whole RecaptchaType service definition if you need extra widget attributes. You can also decorate the HTTP client to add retries or a circuit breaker, which is worth doing if you're seeing Google latency."gjuric/recaptcha-bundle": "1.6.2"), watch upstream's changelog, and be ready to hop back the moment upstream ships the equivalent. 0 stars and 0 dependents still means you own the support burden for anything that breaks next Symfony cycle.How can I help you explore Laravel packages today?