josiasmontag/laravel-recaptchav3
Laravel Recaptcha V3 is a lightweight package for backend validation of Google reCAPTCHA v3. It provides JS initialization, invisible form fields, and a recaptchav3 validator accepting an action and minimum score, configured via site and secret keys.
Install and wire up credentials
composer require josiasmontag/laravel-recaptchav3
RECAPTCHAV3_SITEKEY=your-site-key
RECAPTCHAV3_SECRET=your-secret-key
The service provider and RecaptchaV3 facade are auto-discovered. Publish the config only if you need to change defaults:
php artisan vendor:publish --provider="Lunaweb\RecaptchaV3\Providers\RecaptchaV3ServiceProvider"
Where to look first (in this order):
config/recaptchav3.php — sitekey, secret, and the HTTP options (timeout). This is 90% of the knobs you'll ever touch.Lunaweb\RecaptchaV3\RecaptchaV3 — getSiteKey() and verifyResponse($token, $action, $threshold).Lunaweb\RecaptchaV3\RecaptchaV3Rule — the validation rule; read it once to see exactly how score and action are compared.Lunaweb\RecaptchaV3\Response — the value object returned by verifyResponse() (isSuccess(), getScore(), getAction(), getErrorCodes()).First use case: a contact form. Two moving parts — the browser gets a token, the server verifies it.
Frontend (fetch the token on submit, not on page load — tokens expire in ~2 minutes and are single-use):
<form method="POST" action="{{ route('contact.store') }}" id="contact-form">
@csrf
@recaptchaV3
<input type="hidden" name="g-recaptcha-response" id="recaptcha-token">
{{-- your fields --}}
</form>
<script>
document.getElementById('contact-form').addEventListener('submit', function (e) {
if (document.getElementById('recaptcha-token').value) return;
e.preventDefault();
const form = e.target;
grecaptcha.ready(function () {
grecaptcha.execute('{{ config('recaptchav3.sitekey') }}', { action: 'contact_us' })
.then(function (token) {
document.getElementById('recaptcha-token').value = token;
form.submit();
});
});
});
</script>
Backend:
use Lunaweb\RecaptchaV3\RecaptchaV3Rule;
$request->validate([
'g-recaptcha-response' => ['required', new RecaptchaV3Rule('contact_us', 0.5)],
'email' => ['required', 'email'],
]);
That's the whole happy path. Everything below is about doing it at scale.
Rule-first for form posts, manual verification for everything else. The validation rule is the idiomatic path: it throws a ValidationException you already know how to surface, and it returns the error to the user in the standard $errors bag. Reach for the facade when there's no Request::validate() in play — API endpoints, Livewire actions, queued jobs that re-verify, or an Artisan command.
use Lunaweb\RecaptchaV3\Facades\RecaptchaV3;
$response = RecaptchaV3::verifyResponse(
$request->input('g-recaptcha-response'),
'newsletter_signup',
0.5
);
if (! $response->isSuccess() || $response->getScore() < 0.5) {
Log::warning('recaptcha rejected', [
'action' => $response->getAction(),
'score' => $response->getScore(),
'errors' => $response->getErrorCodes(),
'ip' => $request->ip(),
]);
abort(422, 'Captcha verification failed.');
}
Check both isSuccess() and the score yourself in manual flows — it costs nothing and makes the failure mode explicit when you're debugging a threshold decision six months from now.
One action name per form, kept in a constant. Action names are free-form strings that Google echoes back. Typos are the #1 silent failure (action-mismatch in the error codes). Put them on a small class or in config so the Blade template and the rule can't drift:
final class RecaptchaAction
{
public const CONTACT = 'contact_us';
public const REGISTER = 'register';
public const NEWSLETTER = 'newsletter_signup';
}
Log the score before you enforce it. When you first ship this, set the threshold low (or log-only) and watch the score distribution for a week. Real scores cluster at 0.7–0.9 for humans; a hard 0.9 cutoff will bounce legitimate users on corporate VPNs and privacy browsers. A useful pattern is a middleware that verifies and annotates the request, letting each controller decide whether a low score blocks or just flags the submission for moderation:
class VerifyRecaptcha
{
public function handle($request, Closure $next, string $action, float $threshold = 0.5)
{
$result = RecaptchaV3::verifyResponse($request->input('g-recaptcha-response'), $action, $threshold);
$request->attributes->set('recaptcha', $result);
if (! $result->isSuccess() || $result->getScore() < $threshold) {
abort(422, 'Captcha verification failed.');
}
return $next($request);
}
}
Then Route::post('/contact', ...)->middleware('recaptcha:contact_us,0.5'). This keeps controllers clean and makes the policy visible in routes/web.php.
Testing. Don't let the suite make outbound HTTP calls to Google. Two viable approaches:
Http::fake(['https://www.google.com/recaptcha/api/siteverify' => Http::response(['success' => true, 'score' => 0.9, 'action' => 'contact_us'])]);setUp() so verifyResponse() returns a stubbed Response.Either way, write the failure test too (score 0.1, error code timeout-or-duplicate) — the passing path is never the one that breaks in production. Google's documented test keys are v2-oriented; don't assume they'll give you predictable v3 scores.
Multi-tenant / multiple domains. The client is a container singleton built from the config array at resolution time. If you need different keys per tenant, set the config before the client resolves, or forget/re-register the instance — mutating config() after the first verifyResponse() call silently does nothing.
grecaptcha.execute(key, {action: 'contact_us'}) and the server verifies with 'login', verification fails regardless of score. Same string, both sides.g-recaptcha-response is not implicit. Pair the rule with 'required', otherwise a missing token sneaks past the rule's own checks (or produces a confusing "invalid response" message instead of "this field is required").RECAPTCHAV3_* changes require php artisan config:clear in dev. A stale cached config is the usual cause of "it works locally but not on the server".timeout to something small (3–5 s) so a Google hiccup doesn't hang your request cycle. Don't call it twice for the same submission.missing-input-response (token never reached the server — check the hidden field name), invalid-input-response (malformed/expired), timeout-or-duplicate (already used, or too old), action-mismatch, invalid-input-secret (wrong env). Log getErrorCodes() before you guess.src/RecaptchaV3Rule.php and override it in lang/{locale}/validation.php so you can write copy like "We couldn't verify your submission, please try again."/login lock out real users with no recovery path. If you use it there, treat low scores as "require 2FA" rather than "deny".RecaptchaV3 binding in your own decorator that logs getScore() / getAction() on every verification. You get a free dataset for tuning thresholds and can later feed it into an alert ("scores dropped for all users → check the key/project in the Google console").How can I help you explore Laravel packages today?