Add a Google reCAPTCHA to a Laravel form, without a package

Add a Google reCAPTCHA to a Laravel form, without a package
Quick answer

No package needed: a class implementing ValidationRule that calls https://www.google.com/recaptcha/api/siteverify through the Http facade is all it takes. Check three things in the response: success, the expected action and the score. And always pair the rule with required: Laravel does not run a custom rule on an empty field.

A reCAPTCHA comes down to two things: a token produced by the browser, and a check of that token against Google from your server. Laravel gives you everything you need for the second part, which makes the third-party package optional.

Part of the Web development learning path. This guide builds a self-contained validation rule, with no dependency, and puts it through six scenarios. Verified on Laravel 13.30.1 with PHP 8.4.25.

Getting the keys

Create a site in the reCAPTCHA console and pick v3, which assigns a score from 0 to 1 without asking the visitor anything. You get two keys: the site key, which is public, and the secret key, which must never leave the server.

.env
RECAPTCHA_KEY=votre_cle_de_site
RECAPTCHA_SECRET=votre_cle_secrete

Declare them in the configuration rather than calling env() from your code: once the configuration is cached, env() returns null anywhere outside config/.

config/services.php
'recaptcha' => [
    'key'    => env('RECAPTCHA_KEY'),
    'secret' => env('RECAPTCHA_SECRET'),
],

The validation rule

A class implementing ValidationRule is enough. It calls the verification API and checks the success flag, the expected action and the score.

bash
php artisan make:rule Recaptcha
app/Rules/Recaptcha.php
<?php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Http;

class Recaptcha implements ValidationRule
{
    public function __construct(
        private string $action,
        private float $minScore = 0.5,
    ) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (! is_string($value) || $value === '') {
            $fail('La vérification anti-robot est absente.');
            return;
        }

        $response = Http::asForm()
            ->timeout(5)
            ->post('https://www.google.com/recaptcha/api/siteverify', [
                'secret'   => config('services.recaptcha.secret'),
                'response' => $value,
                'remoteip' => request()->ip(),
            ]);

        if ($response->failed()) {
            $fail('La vérification anti-robot est indisponible. Réessayez.');
            return;
        }

        $data = $response->json();

        if (! ($data['success'] ?? false)) {
            $fail('La vérification anti-robot a échoué.');
            return;
        }

        if (($data['action'] ?? null) !== $this->action) {
            $fail('La vérification anti-robot ne correspond pas à ce formulaire.');
            return;
        }

        if (($data['score'] ?? 0) < $this->minScore) {
            $fail('Votre requête a été considérée comme automatisée.');
        }
    }
}

The action check deserves a word of explanation: without it, a token obtained on any public page of your site could be used to pass the contact form validation. The score on its own is not enough.

Wiring the rule into the form

Add required, or the protection is worthless

Laravel does not run a custom rule on an empty or missing value. Verified: with the Recaptcha rule alone, an empty field passes validation without a single request ever going out to Google. Simply not sending the field would be enough to bypass the captcha. The required rule is not optional here.

app/Http/Controllers/ContactController.php
use App\Rules\Recaptcha;

public function store(Request $request)
{
    $request->validate([
        'email'                => ['required', 'email'],
        'message'              => ['required', 'string', 'max:2000'],
        'g-recaptcha-response' => ['required', new Recaptcha('contact', 0.5)],
    ]);

    // …
}

The form in the browser

The form below relies on Blade’s escaping rules. v3 shows no checkbox at all: the script generates a token, which you drop into a hidden field just before the form is sent.

resources/views/contact.blade.php
<form method="POST" action="/contact" id="contact">
    @csrf
    <input type="email" name="email" required>
    <textarea name="message" required></textarea>
    <input type="hidden" name="g-recaptcha-response" id="recaptcha-token">
    <button type="submit">Envoyer</button>
</form>

@error('g-recaptcha-response')
    <p class="erreur">{{ $message }}</p>
@enderror

<script src="https://www.google.com/recaptcha/api.js?render={{ config('services.recaptcha.key') }}"></script>
<script>
document.getElementById('contact').addEventListener('submit', function (e) {
    e.preventDefault();
    grecaptcha.ready(() => {
        grecaptcha.execute(@json(config('services.recaptcha.key')), { action: 'contact' })
            .then(token => {
                document.getElementById('recaptcha-token').value = token;
                e.target.submit();
            });
    });
});
</script>

The action declared here, contact, has to be exactly the one the server-side rule expects.

Testing without calling Google

A protection you never test is a protection whose state you do not know. Remember to turn debug mode off in production as well, or you will expose your secret key in an error page.

Http::fake() lets you simulate every response the API can return:

tests/Feature/RecaptchaTest.php
use App\Rules\Recaptcha;
use Illuminate\Http\Client\Factory;
use Illuminate\Support\Facades\{Http, Validator};

$cas = [
    'succès, score 0.9'       => [200, ['success' => true,  'action' => 'contact', 'score' => 0.9]],
    'succès, score 0.1'       => [200, ['success' => true,  'action' => 'contact', 'score' => 0.1]],
    'action différente'       => [200, ['success' => true,  'action' => 'login',   'score' => 0.9]],
    'jeton refusé par Google' => [200, ['success' => false, 'error-codes' => ['invalid-input-response']]],
    'service indisponible'    => [500, ''],
];

foreach ($cas as $label => [$code, $corps]) {
    Http::swap(new Factory());   // starts from a fresh client for each case
    Http::fake(['*' => Http::response($corps, $code)]);

    $v = Validator::make(
        ['g-recaptcha-response' => 'un-jeton'],
        ['g-recaptcha-response' => ['required', new Recaptcha('contact', 0.5)]],
    );

    printf("%-26s %s\n", $label, $v->passes() ? 'ACCEPTÉ' : 'refusé : '.$v->errors()->first());
}

The results:

code
succès, score 0.9          ACCEPTÉ
succès, score 0.1          refusé : Votre requête a été considérée comme automatisée.
action différente          refusé : La vérification anti-robot ne correspond pas à ce formulaire.
jeton refusé par Google    refusé : La vérification anti-robot a échoué.
service indisponible       refusé : La vérification anti-robot est indisponible. Réessayez.
Http::fake() stacks its fakes

Calling Http::fake() several times in the same test does not clear the previous ones: the first match answers, and every case after it gets that first response. Hence the Http::swap(new Factory()) before each case. Without it, the whole series goes green without checking anything.

Do you need a package?

anhskohbo/no-captcha, quoted in the previous version of this article, is still around in version 3.8. It saves you a few lines, essentially the rendering of the v2 widget.

The code above fits into one class and one HTTP call, with no dependency to keep up with, and leaves you in control of the minimum score, the action check and the error messages. On a single form, that is the better deal.

Worth knowing if you are starting from scratch: Cloudflare Turnstile does the same job with a verification API of the same shape. Only the verification URL and the field name change, the structure of the rule stays identical.

Common errors

Forgetting required alongside the rule Laravel does not run a custom rule on an empty or missing value. Without required, not sending the field at all is enough to bypass the captcha entirely. Verified.
Not checking the action A token obtained on any page of the site would pass the form validation. The score alone protects nothing.
Calling env() from the rule As soon as the configuration is cached, env() returns null. Go through config('services.recaptcha.secret') instead.
Http::fake() stacks its fakes A second call does not clear the first: the first match is what answers. Without Http::swap(new Factory()), the whole test series goes green without checking anything.
No timeout on the call Without timeout(), an outage of the API blocks the user’s request until PHP’s default timeout.
Exposing the secret key Only the site key goes into the HTML. The secret key stays on the server.

FormulairesLaravelPHPSécurité

Damien Flandrin Web developer since 2010, creator of Gekkode and Email Impact. Every article is tested on a real project before publication. Contact
Newsletter

New tests, tutorials and projects, by e-mail.

Reproducible tests, versioned code, dated results. Never any spam.