
No hace falta ningún paquete: basta con una clase que implemente ValidationRule y consulte https://www.google.com/recaptcha/api/siteverify con la fachada Http. Comprueba tres cosas en la respuesta: success, la action esperada y el score. Y acompaña siempre la regla con required: Laravel no ejecuta una regla personalizada sobre un campo vacío.
Un reCAPTCHA se reduce a dos cosas: un token que genera el navegador y una comprobación de ese token contra Google desde tu servidor. Laravel trae todo lo necesario para la segunda parte, lo que convierte el paquete de terceros en algo opcional.
Artículo del itinerario Desarrollo web. Esta guía monta una regla de validación autónoma, sin dependencias, y la somete a seis escenarios. Verificado en Laravel 13.30.1 con PHP 8.4.25.
Conseguir las claves
Crea un sitio en la consola de reCAPTCHA y elige la versión v3, que asigna una puntuación de 0 a 1 sin pedirle nada al visitante. Obtienes dos claves: la clave de sitio, pública, y la clave secreta, que no debe salir nunca del servidor.
RECAPTCHA_KEY=votre_cle_de_site
RECAPTCHA_SECRET=votre_cle_secreteDecláralas en la configuración en lugar de llamar a env() desde el código: una vez que la configuración está en caché, env() devuelve null en cualquier sitio que no sea config/.
'recaptcha' => [
'key' => env('RECAPTCHA_KEY'),
'secret' => env('RECAPTCHA_SECRET'),
],La regla de validación
Basta con una clase que implemente ValidationRule. Consulta la API de verificación y comprueba el éxito, la acción esperada y la puntuación.
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.');
}
}
}La comprobación de la acción merece una explicación: sin ella, un token obtenido en cualquier página pública de tu sitio podría servir para superar la validación del formulario de contacto. La puntuación por sí sola no basta.
Enganchar la regla al formulario
Laravel no ejecuta una regla personalizada sobre un valor vacío o ausente. Comprobado: con la regla Recaptcha a secas, un campo vacío pasa la validación sin que salga ni una petición hacia Google. Bastaría entonces con no enviar el campo para saltarse el captcha. Aquí la regla required no es opcional.
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)],
]);
// …
}El formulario en el navegador
El formulario de abajo se apoya en las reglas de escapado de Blade. La v3 no muestra ninguna casilla: el script genera un token que se coloca en un campo oculto justo antes del envío.
<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>La acción declarada aquí, contact, tiene que ser exactamente la que espera la regla en el servidor.
Probarlo sin llamar a Google
Una protección que no se prueba es una protección cuyo estado desconoces. Acuérdate también de desactivar el modo de depuración en producción, o expondrás tu clave secreta en una página de error.
Http::fake() permite simular cada respuesta de la 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()); // arranca con un cliente nuevo en cada caso
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());
}Resultados obtenidos:
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.Llamar a Http::fake() varias veces en el mismo test no borra las anteriores: responde la primera coincidencia, y todos los casos siguientes reciben la respuesta del primero. De ahí el Http::swap(new Factory()) antes de cada caso. Sin él, la batería entera pasa en verde sin comprobar nada.
¿Hace falta un paquete?
anhskohbo/no-captcha, citado en la versión anterior de este artículo, sigue existiendo en la versión 3.8. Ahorra unas cuantas líneas, sobre todo las de mostrar el widget de la v2.
El código de arriba cabe en una clase y una llamada HTTP, sin dependencias que mantener, y te deja el control de la puntuación mínima, de la comprobación de la acción y de los mensajes de error. Para un formulario, es la mejor relación.
Un apunte si empiezas de cero: Cloudflare Turnstile cumple el mismo papel con una API de verificación de la misma forma. Solo cambian la URL de verificación y el nombre del campo, la estructura de la regla es idéntica.
Errores frecuentes
required, basta con no enviar el campo para saltarse el captcha por completo. Comprobado.env() devuelve null. Usa config('services.recaptcha.secret').Http::swap(new Factory()), toda la batería de pruebas pasa en verde sin comprobar nada.timeout(), una caída de la API bloquea la petición del usuario hasta el límite por defecto de PHP.

