
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.
RECAPTCHA_KEY=votre_cle_de_site
RECAPTCHA_SECRET=votre_cle_secreteDé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/.
'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.
php artisan make:rule Recaptcha<?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
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.
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.
<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 :
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 :
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.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
required, il suffit de ne pas envoyer le champ pour contourner entièrement le captcha. Vérifié.env() renvoie null. Passez par config('services.recaptcha.secret').Http::swap(new Factory()), toute la série de tests passe au vert sans rien vérifier.timeout(), une panne de l’API bloque la requête de l’utilisateur jusqu’au délai par défaut de PHP.

