mael/recaptcha-bundle
Mael RecaptchaBundle integrates Google reCAPTCHA into your PHP application's forms to help block spam and automated bot submissions. It provides straightforward configuration and server-side verification of user responses, making it easy to add basic bot protection to existing forms.
Stack reality check first. This is a Symfony bundle (MaelRecaptchaBundle), not a Laravel package. There is no service provider, no config/recaptcha.php, no Blade directives, and no ValidationRule. A Laravel developer has exactly two viable ways to "leverage" it:
For day-to-day Laravel work, treat (1) as the primary path. The correct adoption target in a Laravel codebase is biscolab/laravel-recaptcha or anhskohbo/no-captcha, wrapped in your own form request + validation rule so the vendor can be swapped.
Reading order (what to look at first):
README.md — the exact Twig function name, form type name, and config keys. Small single-maintainer bundles rename these between releases; verify against the tagged version you're reading, don't assume.DependencyInjection/Configuration.php — the authoritative list of options and defaults (this is where site_key / secret_key and any timeout/verify-URL options actually live).Form/Type/ — how the widget is added to a Symfony form, and what the submit path looks like.Validator/Constraints/ — how the g-recaptcha-response token is checked server-side. This is the code you're going to copy.Resources/views/ — the Twig template that injects https://www.google.com/recaptcha/api.js.First use case: guard a contact form. In Symfony that means adding the bundle's recaptcha form type to ContactType; in Laravel it means a Blade component that renders the widget and a Recaptcha validation rule that calls siteverify on submit.
Smoke-testing without a real key pair: Google publishes always-passing test keys — 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI (site) and 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe (secret). Use them to confirm your wire-up end-to-end before provisioning production keys, and only in local/test environments.
The bundle does one essential thing: POST secret, response, and remoteip to https://www.google.com/recaptcha/api/siteverify and inspect the JSON. In Laravel this becomes a small service behind an interface, which is the single most valuable thing you get from reading this package: the shape.
// app/Services/Recaptcha/RecaptchaVerifier.php
interface RecaptchaVerifier
{
public function verify(string $token, ?string $ip = null, ?string $action = null): RecaptchaResult;
}
final class GoogleRecaptchaVerifier implements RecaptchaVerifier
{
public function __construct(
private readonly string $secret,
private readonly float $minScore = 0.5,
private readonly int $timeout = 3,
) {}
public function verify(string $token, ?string $ip = null, ?string $action = null): RecaptchaResult
{
$response = Http::asForm()->timeout($this->timeout)->post(
'https://www.google.com/recaptcha/api/siteverify',
array_filter([
'secret' => $this->secret,
'response' => $token,
'remoteip' => $ip,
]),
);
if ($response->failed()) {
return RecaptchaResult::unavailable();
}
$body = $response->json();
if (!($body['success'] ?? false)) {
return RecaptchaResult::failed($body['error-codes'] ?? []);
}
if ($action !== null && ($body['action'] ?? '') !== $action) {
return RecaptchaResult::failed(['action-mismatch']);
}
if (isset($body['score']) && $body['score'] < $this->minScore) {
return RecaptchaResult::failed(['low-score']);
}
return RecaptchaResult::passed();
}
}
Keep the interface so reCAPTCHA can later be swapped for Cloudflare Turnstile or hCaptcha without touching call sites.
This replaces the Symfony FormType + Validator\Constraints pairing the bundle ships. One rule, reused across every form request:
final class RecaptchaRule implements ValidationRule
{
public function __construct(private readonly ?string $action = null) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (!is_string($value) || $value === '') {
$fail('Please complete the CAPTCHA.');
return;
}
$result = app(RecaptchaVerifier::class)->verify(
$value,
request()->ip(),
$this->action,
);
if ($result->isUnavailable()) {
// fail-closed by default; see Gotchas for the fail-open decision
$fail('CAPTCHA verification is temporarily unavailable. Please retry.');
return;
}
if (!$result->passed()) {
$fail('CAPTCHA verification failed. Please retry.');
}
}
}
Then in a form request:
public function rules(): array
{
return [
'email' => ['required', 'email'],
'message' => ['required', 'string', 'max:2000'],
'g-recaptcha-response' => ['required', new RecaptchaRule('contact')],
];
}
The bundle exposes a Twig function that renders the api.js script and the widget. In Laravel this is a Blade component:
{{-- resources/views/components/recaptcha.blade.php --}}
@props(['action' => 'default'])
@once
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
@endonce
<div class="g-recaptcha"
data-sitekey="{{ config('services.recaptcha.site_key') }}"
data-callback="onRecaptcha_{{ str_replace('-', '_', $action) }}">
</div>
In a form:
<form method="POST" action="/contact">
@csrf
...
<x-recaptcha action="contact" />
<button type="submit">Send</button>
</form>
Note the field name g-recaptcha-response is fixed by Google's script — it must match the rule's attribute key exactly.
config/services.php, secrets in .env only)The Symfony bundle puts keys in YAML; in Laravel, mirror the same separation of concerns:
// config/services.php
'recaptcha' => [
'site_key' => env('RECAPTCHA_SITE_KEY'),
'secret_key' => env('RECAPTCHA_SECRET_KEY'),
'min_score' => env('RECAPTCHA_MIN_SCORE', 0.5),
'timeout' => env('RECAPTCHA_TIMEOUT', 3),
],
Never commit real keys. The bundle's YAML pattern is the exact anti-pattern that leads to secrets in git history if copied verbatim — use .env, .env.testing, and CI secrets.
If you go with v3, pass a distinct action per surface (contact, signup, password_reset) and set thresholds per surface at the call site (new RecaptchaRule('signup') with a stricter minScore). This is the day-to-day pattern for scoping bot mitigation — it lets you escalate enforcement on registration without making every contact form unusable.
It's a Symfony bundle — do not composer require it into a Laravel app. It will install (PHP packages don't care), but nothing will auto-register, config/bundles.php won't exist, and you'll spend an afternoon debugging why nothing happens. The install path only applies to Symfony.
Do not treat this as a maintained dependency. Last release 2020-05-23, 2 stars, 0 dependents, single-maintainer namespace. Any release you take is a fork you own. For a Laravel app this is a non-issue if you follow Pattern 1; for a Symfony app it means pinning and budgeting for the eventual Guzzle/Twig/PHP-version conflicts.
success: true alone is not sufficient verification. The classic bypass is checking only the success flag. Always also check — at minimum — that the response corresponds to the expected action (v3), and, for sensitive surfaces, inspect challenge_ts freshness and hostname against your allow-list. The bundle's age makes it unlikely it validates anything beyond success; verify in Validator/Constraints/ before you trust it, and build the extra checks into your own port regardless.
Missing or empty token must fail, not pass. Ad blockers, restricted regions, and script-blocked browsers routinely omit g-recaptcha-response. Several naive verifier ports treat "no token" as "no CAPTCHA configured → allow." Audit for this. The rule above treats empty as a hard failure with a user-facing retry.
Malformed JSON from siteverify must not coerce to pass. If you see code like if (!isset($json['success']))) { return true; } in a port, that's a bypass. Require an explicit truthy success and an explicit parsed body; treat parse failure as unavailable.
Fail-open vs fail-closed is a per-surface product decision, not a default. Registration and password reset: fail closed. Low-risk contact/newsletter: fail open is defensible for conversion, but log it so you can see Google outages. Bake this into RecaptchaResult::unavailable() handling at the call site; don't hardcode it in the verifier.
siteverify is a synchronous third-party call on every protected submit. Tokens are single-use with ~2-minute validity, so you cannot cache or batch. Budget for per-request latency (typically 50–200 ms), a hard timeout (2–3 s), and connection reuse. This latency is the real scaling cost of any reCAPTCHA integration — not the package choice.
Test keys are always-pass and are the #1 source of prod incidents. Google's documented test key pair (6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI / 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe) always succeeds. Guard against leaking them into production config/services.php. A useful assertion: in production, throw on boot if RECAPTCHA_SECRET_KEY equals the test secret.
TLS verification is non-negotiable. If you find a HTTP::withoutVerifying() or 'verify' => false in an inherited HTTP client wired to siteverify, that's a bypass and a credential-handling risk. Keep Laravel's default Http (cURL with verification) and don't disable it during debugging.
v2 vs v3 is not a detail — it changes your whole UX contract. The bundle is 2020-era and almost certainly v2 checkbox only. If you're writing Laravel glue, decide v2 (explicit checkbox, user-visible friction) vs v3 (invisible, score-based, needs render=/onload= params and an action/threshold policy) up front. v3 requires grecaptcha.ready() + execute() in the Blade component; a static <div class="g-recaptcha"> is v2 and will silently do nothing on a v3 key or vice versa.
Secrets must not live in committed config. The Symfony YAML pattern (mael_recaptcha: { secret_key: ... }) is what gets copied into git. Use env() only, and keep .env.example with empty placeholders.
Extension points you actually want to keep. The bundle's shape — a service you can call and a form type — is the right shape to reproduce. Keep RecaptchaVerifier behind an interface, keep the rule small, keep the Blade component presentational. Then swapping Google for Turnstile/hCaptcha later (a privacy/GDPR question worth an explicit decision, not a default) is a single binding change in a service provider.
For Symfony users who do install it: check the bundle's Resources/config/services.yaml (or equivalent) for how the HTTP client is wired, and confirm the outbound client is not constructing its own cURL handle with verification disabled. Also check the Configuration.php for a verify-URL option — if it's hardcoded to https://www.google.com/recaptcha/api/siteverify, that's fine, but there's no Enterprise host override, which is one more reason to treat it as v2-only.
Logging and observability. Add a structured log line on every verifier outcome — passed, failed (with error-codes), unavailable — tagged by action and endpoint. Without this you can't tell a real bot spike from a Google outage from your own secret-rotation mistake, and the bundle gives you none of this out of the box.
Bottom line for day-to-day use: read it, port the ~40-line verify path, wrap it behind an interface and a ValidationRule, and use biscolab/laravel-recaptcha or anhskohbo/no-captcha as the reference Laravel integration rather than this Symfony bundle. The cost either way is 1–2 days; the decision that actually matters is which bot-mitigation vendor you standardize on and what your fail-open/fail-closed policy is per surface.
How can I help you explore Laravel packages today?