meteo-concept/hcaptcha-bundle
Symfony bundle that adds hCaptcha protection to your forms with minimal setup. Includes a dedicated form type and server-side validation to block bots and spam, with simple configuration for Symfony applications.
Install and wire it up
composer require meteo-concept/hcaptcha-bundle
As of v4.5.0 the bundle supports Symfony 6.4, 7.4 and 8.0. Intermediate Symfony minors (6.0–6.3, 7.0–7.3) are no longer explicitly tested, so on those versions you're on your own — bump to a supported minor before reporting issues. Symfony 8 implies PHP 8.4, so Composer will pick the right PHP constraint for you when you run composer require.
Symfony Flex registers the bundle automatically; on a non-Flex app add MeteoConcept\HCaptchaBundle\MeteoConceptHCaptchaBundle to config/bundles.php (unchanged in 4.5.0).
Grab a site key (public) and secret (private) from the hCaptcha dashboard, then configure:
# config/packages/meteo_concept_h_captcha.yaml
meteo_concept_h_captcha:
site_key: '%env(HCAPTCHA_SITE_KEY)%'
secret: '%env(HCAPTCHA_SECRET)%'
enabled: true
verify_ssl: true
HCAPTCHA_SITE_KEY=10000000-ffff-ffff-ffff-000000000001
HCAPTCHA_SECRET=0x0000000000000000000000000000000000000000
(The values above are hCaptcha's public test keys — handy for local development.)
Where to look first
MeteoConcept\HCaptchaBundle\Form\Type\HCaptchaType — the form type you'll add to forms.MeteoConcept\HCaptchaBundle\Service\HCaptchaInterface — the service that talks to hCaptcha's /siteverify endpoint.MeteoConcept\HCaptchaBundle\Validator\Constraints\HCaptcha — the constraint the form type attaches for you.Resources/views/form/) — the widget that emits the h-captcha container and, depending on version, the js.hcaptcha.com script tag. If your layout doesn't render it, add <script src="https://js.hcaptcha.com/1/api.js" async defer></script> once per page.First use case: protect a contact form
use MeteoConcept\HCaptchaBundle\Form\Type\HCaptchaType;
$builder
->add('name', TextType::class)
->add('email', EmailType::class)
->add('message', TextareaType::class)
->add('captcha', HCaptchaType::class) // maps to the submission token
->add('send', SubmitType::class);
Render with {{ form_row(form.captcha) }}, submit, and $form->isValid() returns false when the challenge wasn't solved. That's the whole loop — no manual verify call, no controller plumbing.
Form-first is the happy path. HCaptchaType carries the HCaptcha constraint itself, so validation happens during normal form validation. The violation is attached to the captcha field, which means: render that field's errors (form_errors(form.captcha)) or you'll get a mysteriously invalid form with no visible reason. A flash message keyed on $form->get('captcha')->getErrors() is a common fix.
Non-form flows need the service directly. Login forms built with the authenticator system, JSON APIs, or Livewire-style endpoints don't go through Form validation. Inject and call:
use MeteoConcept\HCaptchaBundle\Service\HCaptchaInterface;
public function __construct(private HCaptchaInterface $hcaptcha) {}
public function __invoke(Request $request): Response
{
$token = $request->request->get('h-captcha-response');
if (!$this->hcaptcha->verify($token, $request->getClientIp())) {
throw new BadRequestHttpException('Captcha verification failed.');
}
// ...
}
This is the same check the validator performs, so behaviour stays consistent between forms and hand-rolled endpoints.
Turning it off for dev/test. enabled: false makes verification a no-op that always passes. Drive it from an env var so test and dev environments don't need real keys and your Behat/Panther suites don't have to solve challenges:
meteo_concept_h_captcha:
enabled: '%env(bool:HCAPTCHA_ENABLED)%'
Testing strategy. Two complementary approaches:
HCaptchaInterface rather than the HTTP client — it's a one-method interface, so createMock() is trivial and keeps tests network-free.Customising the widget. hCaptcha supports theme, size, language and tabindex attributes. These are usually passed as form-type options or set on the underlying widget template. Override the bundle's form theme block in templates/form/ for per-project styling, and use the hl= query parameter on the script tag for language.
Decorating for observability. Because the verification is a single interface, a decorator is the natural extension point: log failures with the remote IP and the response code, emit a metric, or fall back to a second provider.
Tokens are single-use and short-lived. hCaptcha tokens expire in roughly two minutes and cannot be replayed. If your form fails validation for an unrelated field and you re-render, the widget shows a fresh challenge but the stale token in the request is gone — make sure the rendered form always includes a freshly rendered widget, and don't retry a POST programmatically with the same payload.
enabled: false fails open, silently. There is no warning in the response and no log entry saying "captcha skipped." A dev override leaking into production config is the most likely way this package ends up protecting nothing. Assert the config value in a smoke test or a health-check endpoint.
Network failure looks like a human failure. The verify call is an outbound HTTPS request (via Symfony HttpClient). Timeouts, DNS issues, or an expired secret all surface as "captcha invalid" to the end user. If your error message is the generic one, you'll spend an hour debugging your form before checking the logs. Log the raw /siteverify response in staging.
The secret never belongs in the site key field. Site key is public and appears in the HTML; secret is server-side only. Swapping them yields a widget that renders but never validates, with no obvious error.
Proxy IPs. verify() accepts an optional remote IP. Behind a load balancer, $request->getClientIp() is only meaningful if trusted_proxies is configured — otherwise you'll send the balancer's IP and, in the worst case, trip hCaptcha's rate heuristics.
Multiple widgets on one page. api.js auto-scans h-captcha elements on load. If you inject a form via Turbo/HTMX/Stimulus after initial load, the new widget won't initialise until you call hcaptcha.render() yourself or re-execute the script. This bites everyone using Turbo Drive at least once.
Keep CSRF. Captcha and CSRF solve different problems; dropping _token because you added a captcha is a regression.
Validation groups. If the form or entity uses explicit validation groups, confirm the captcha field is in the group being validated — otherwise the constraint is skipped and the form is valid with an empty token.
Custom error messages and error bubbling. The constraint ships a default message; override it in your translation catalogue under the constraint's message key rather than hardcoding a string in the controller, so it stays translatable alongside the rest of the form.
Framework version support is now a compatibility axis (v4.5.0). 4.5.0 adds Symfony 8.0 support while keeping 6.4 and 7.4; 6.0–6.3 and 7.0–7.3 are no longer in the test matrix. Practical consequences:
^4.5 if you're on Symfony 8 — earlier 4.x releases won't declare compatibility and Composer will block the upgrade even though the API is unchanged.HCaptchaType, HCaptchaInterface, HCaptcha constraint) is unchanged in this release, so no code changes are expected.verify_ssl/HttpClient config and any decorators don't rely on removed Symfony options before bumping.How can I help you explore Laravel packages today?