To add Google reCAPTCHA v3 validation in Laravel 13, load Google's script with your site key, request a token with grecaptcha.execute() when the form is submitted, send it in a hidden field, and verify it on the server with a custom validation rule that calls Google's siteverify endpoint through Laravel's HTTP client. The rule should check success, the expected action and a minimum score.
Unlike the checkbox version, reCAPTCHA v3 never shows a challenge. It returns a score from 0.0 to 1.0 and leaves the decision to you, so the server-side check is the important part.
Prerequisites
- Laravel 13 with PHP 8.3 or newer.
- A reCAPTCHA v3 site key and secret key from the Google reCAPTCHA admin console, with your domains (and
localhostfor development) registered. - A form to protect, such as registration or contact.
Step 1: Store the keys in config
RECAPTCHA_SITE_KEY=your-site-key
RECAPTCHA_SECRET_KEY=your-secret-key
RECAPTCHA_MIN_SCORE=0.5Add them to config/services.php so the app reads them through config(), which keeps working after php artisan config:cache:
'recaptcha' => [
'site_key' => env('RECAPTCHA_SITE_KEY'),
'secret_key' => env('RECAPTCHA_SECRET_KEY'),
'min_score' => (float) env('RECAPTCHA_MIN_SCORE', 0.5),
],Google suggests 0.5 as a starting threshold. Review real scores in the admin console after launch and adjust.
Step 2: Create a validation rule
php artisan make:rule Recaptcha<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class Recaptcha implements ValidationRule
{
public function __construct(
protected string $action,
) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || $value === '') {
$fail('Please confirm you are not a robot.');
return;
}
try {
$response = Http::asForm()
->timeout(5)
->post('https://www.google.com/recaptcha/api/siteverify', [
'secret' => config('services.recaptcha.secret_key'),
'response' => $value,
'remoteip' => request()->ip(),
]);
} catch (ConnectionException $e) {
Log::warning('reCAPTCHA verification unavailable', ['error' => $e->getMessage()]);
$fail('We could not verify the request. Please try again.');
return;
}
$result = $response->json();
$passed = ($result['success'] ?? false) === true
&& ($result['action'] ?? null) === $this->action
&& ($result['score'] ?? 0) >= config('services.recaptcha.min_score');
if (! $passed) {
Log::info('reCAPTCHA rejected', [
'action' => $result['action'] ?? null,
'score' => $result['score'] ?? null,
'errors' => $result['error-codes'] ?? [],
]);
$fail('Our spam check did not pass. Please try again.');
}
}
}The action check matters. A token created for your newsletter form should not be accepted by the registration form. Logging the score and error codes, without the token itself, helps you tune the threshold later.
If you want to be strict about the domain too, compare $result['hostname'] with your app host.
Step 3: Use the rule in a form request
<?php
namespace App\Http\Requests;
use App\Rules\Recaptcha;
use Illuminate\Foundation\Http\FormRequest;
class ContactRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:100'],
'email' => ['required', 'email', 'max:255'],
'message' => ['required', 'string', 'max:2000'],
'recaptcha_token' => ['required', new Recaptcha('contact')],
];
}
}Step 4: Request a token in the browser
Tokens expire after two minutes, so request one when the user submits, not when the page loads:
<script src="https://www.google.com/recaptcha/api.js?render={{ config('services.recaptcha.site_key') }}"></script>
<form id="contact-form" method="POST" action="{{ route('contact.store') }}">
@csrf
<input name="name" value="{{ old('name') }}" required>
<input type="email" name="email" value="{{ old('email') }}" required>
<textarea name="message" required>{{ old('message') }}</textarea>
<input type="hidden" name="recaptcha_token" id="recaptcha_token">
@error('recaptcha_token') <p class="error">{{ $message }}</p> @enderror
<button type="submit">Send</button>
</form>
<script>
document.getElementById('contact-form').addEventListener('submit', function (event) {
event.preventDefault();
const form = this;
grecaptcha.ready(function () {
grecaptcha.execute('{{ config('services.recaptcha.site_key') }}', { action: 'contact' })
.then(function (token) {
document.getElementById('recaptcha_token').value = token;
form.submit();
});
});
});
</script>The action name in JavaScript (contact) must match the one passed to the rule. Google's terms also require you to show the reCAPTCHA badge or an equivalent notice on the page.
What should you do with low scores?
Rejecting outright is simplest, but it can block real users on VPNs or privacy browsers. Alternatives: accept the submission but flag it for review, require email verification, or show a second step. Start strict on high-risk forms such as registration and looser on low-risk ones.
Common mistakes
- Verifying in JavaScript only. The browser can be bypassed. The server check is the real protection.
- Reading
env()outside config files. Afterconfig:cache,env()returns null in your code. Useconfig('services.recaptcha...'). - Executing on page load. If the user takes more than two minutes, the token has expired and verification fails with
timeout-or-duplicate. - Reusing a token. Each token can be verified once. Request a fresh one for every submit.
How do you verify it works?
Fake Google's response with Http::fake() so tests do not call the real API:
<?php
use Illuminate\Support\Facades\Http;
test('a low score is rejected', function () {
Http::fake([
'www.google.com/recaptcha/*' => Http::response(['success' => true, 'action' => 'contact', 'score' => 0.1]),
]);
$this->post('/contact', [
'name' => 'Ali',
'email' => 'ali@example.com',
'message' => 'Hello there',
'recaptcha_token' => 'fake-token',
])->assertSessionHasErrors('recaptcha_token');
});
test('a good score passes', function () {
Http::fake([
'www.google.com/recaptcha/*' => Http::response(['success' => true, 'action' => 'contact', 'score' => 0.9]),
]);
$this->post('/contact', [
'name' => 'Ali',
'email' => 'ali@example.com',
'message' => 'Hello there',
'recaptcha_token' => 'fake-token',
])->assertSessionHasNoErrors();
});FAQ
Do I need a package for reCAPTCHA in Laravel?
No. One validation rule and the HTTP client are enough, and you control the logic.
Can I use reCAPTCHA v3 with Livewire or Inertia?
Yes. Get the token in JavaScript before calling the action or posting the form, and validate it with the same rule.
Is rate limiting still needed?
Yes. Combine reCAPTCHA with Laravel's throttle middleware for defence in depth.



