Ajouter un Google reCAPTCHA à un formulaire Laravel

Ajouter un Google reCAPTCHA à un formulaire Laravel
Réponse rapide

Pas besoin de paquet : une classe implémentant ValidationRule qui interroge https://www.google.com/recaptcha/api/siteverify avec la façade Http suffit. Contrôlez trois choses dans la réponse : success, l’action attendue et le score. Et associez toujours la règle à required : Laravel n’exécute pas une règle personnalisée sur un champ vide.

Un reCAPTCHA se résume à deux choses : un jeton produit par le navigateur, et une vérification de ce jeton auprès de Google depuis votre serveur. Laravel fournit tout ce qu’il faut pour la seconde partie, ce qui rend le paquet tiers facultatif.

Article du parcours Développement web. Ce guide monte une règle de validation autonome, sans dépendance, et la met à l’épreuve sur six scénarios. Vérifié sur Laravel 13.30.1 avec PHP 8.4.25.

Obtenir les clés

Créez un site sur la console reCAPTCHA et choisissez la version v3, qui attribue un score de 0 à 1 sans rien demander au visiteur. Vous obtenez deux clés : la clé de site, publique, et la clé secrète, qui ne doit jamais quitter le serveur.

.env
RECAPTCHA_KEY=votre_cle_de_site
RECAPTCHA_SECRET=votre_cle_secrete

Déclarez-les dans la configuration plutôt que d’appeler env() depuis le code : une fois la configuration mise en cache, env() renvoie null partout ailleurs que dans config/.

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

La règle de validation

Une classe implémentant ValidationRule suffit. Elle interroge l’API de vérification, contrôle le succès, l’action attendue et le 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.');
        }
    }
}

Le contrôle de l’action mérite une explication : sans lui, un jeton obtenu sur une page publique quelconque de votre site pourrait servir à passer la validation du formulaire de contact. Le score seul ne suffit pas.

Brancher la règle sur le formulaire

Ajoutez required, sinon la protection ne sert à rien

Laravel n’exécute pas une règle personnalisée sur une valeur vide ou absente. Vérifié : avec la seule règle Recaptcha, un champ vide passe la validation sans qu’aucune requête ne parte vers Google. Il suffirait donc de ne pas envoyer le champ pour contourner le captcha. La règle required n’est pas optionnelle ici.

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)],
    ]);

    // …
}

Le formulaire côté navigateur

Le formulaire ci-dessous s’appuie sur les règles d’échappement de Blade. La v3 n’affiche aucune case à cocher : le script génère un jeton qu’on place dans un champ caché juste avant l’envoi.

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>

L’action déclarée ici, contact, doit être exactement celle attendue par la règle côté serveur.

Tester sans appeler Google

Une protection qu’on ne teste pas est une protection dont on ignore l’état. Pensez aussi à désactiver le mode débogage en production, sous peine d’exposer votre clé secrète dans une page d’erreur.

Http::fake() permet de simuler chaque réponse de l’API :

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());   // repart d'un client neuf à chaque cas
    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());
}

Résultats obtenus :

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() cumule les simulations

Appeler Http::fake() plusieurs fois dans le même test n’efface pas les précédentes : c’est la première correspondance qui répond, et tous les cas suivants reçoivent la réponse du premier. D’où le Http::swap(new Factory()) avant chaque cas. Sans lui, la série entière passe au vert sans rien vérifier.

Faut-il un paquet ?

anhskohbo/no-captcha, cité dans la version précédente de cet article, existe toujours en version 3.8. Il fait gagner quelques lignes, essentiellement l’affichage du widget v2.

Le code ci-dessus tient en une classe et un appel HTTP, sans dépendance à suivre, et vous laisse le contrôle du score minimal, du contrôle d’action et des messages d’erreur. Sur un formulaire, c’est le meilleur rapport.

À noter si vous partez de zéro : Cloudflare Turnstile remplit le même rôle avec une API de vérification de même forme. Seuls l’URL de vérification et le nom du champ changent, la structure de la règle reste identique.

Erreurs fréquentes

Oublier required à côté de la règle Laravel n’exécute pas une règle personnalisée sur une valeur vide ou absente. Sans required, il suffit de ne pas envoyer le champ pour contourner entièrement le captcha. Vérifié.
Ne pas contrôler l’action Un jeton obtenu sur n’importe quelle page du site passerait la validation du formulaire. Le score seul ne protège pas.
Appeler env() depuis la règle Dès que la configuration est en cache, env() renvoie null. Passez par config('services.recaptcha.secret').
Http::fake() cumule les simulations Un second appel n’efface pas le premier : c’est la première correspondance qui répond. Sans Http::swap(new Factory()), toute la série de tests passe au vert sans rien vérifier.
Aucun délai d’expiration sur l’appel Sans timeout(), une panne de l’API bloque la requête de l’utilisateur jusqu’au délai par défaut de PHP.
Exposer la clé secrète Seule la clé de site va dans le HTML. La clé secrète reste côté serveur.

FormulairesLaravelPHPSécurité

Damien Flandrin Développeur web depuis 2010, créateur de Gekkode et d’Email Impact. Chaque article est testé sur un projet réel avant publication. Contact
Newsletter

Les nouveaux tests, tutoriels et projets, par e-mail.

Tests reproductibles, code versionné, résultats datés. Jamais de spam.