En este tutorial vamos a ver cómo integrar Cloudflare Turnstile en Laravel para proteger un formulario de contacto frente a los envíos automatizados. Usaremos Blade para mostrar los campos y Laracaptcha para comprobar en el servidor el token que genera Cloudflare.

El ejemplo incluye la validación de los datos, los mensajes de error y el envío de la consulta por correo. Así podrás adaptarlo a un registro, una solicitud de presupuesto o ese formulario que hasta ahora solo recibe ofertas para mejorar tu formulario.

Introducción

Antes de continuar, te vendrá bien tener a mano estas guías:

Necesitarás PHP 8.3 o posterior y Composer. El formulario terminado tendrá este aspecto:

Formulario de contacto con Laravel, Blade y Turnstile

Instala las dependencias

Partiremos de Laravel 13 y añadiremos únicamente Laracaptcha. Este paquete reúne el componente Blade que dibuja Turnstile y la regla de validación que consulta a Cloudflare. El navegador obtiene un token; Laravel decide si lo acepta antes de enviar el correo.

Para preparar un proyecto nuevo podemos utilizar estos comandos. Si ya tienes una aplicación, ejecuta solamente la instalación del paquete dentro de ella:

composer create-project laravel/laravel:^13.0 contacto-turnstile
cd contacto-turnstile
composer require edulazaro/laracaptcha:^1.2

Configura Turnstile

Durante el desarrollo utilizaremos las claves de prueba de Cloudflare, que permiten resolver el widget sin registrar un dominio. Añade estas variables al archivo .env. Son credenciales públicas de pruebas y tendrás que sustituirlas al publicar la aplicación:

CAPTCHA_DRIVER=turnstile
TURNSTILE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET=1x0000000000000000000000000000000AA

CACHE_STORE=file
SESSION_DRIVER=file
MAIL_MAILER=log
MAIL_FROM_ADDRESS=web@example.com
MAIL_FROM_NAME="Mi aplicación"
CONTACT_RECIPIENT=contacto@example.com

Usaremos archivos para la sesión y la caché, de manera que este ejemplo no necesita tablas propias. Si tenías la configuración cacheada, actualízala con php artisan config:clear.

Prepara el correo de contacto

Antes de proteger el formulario conviene dejar preparado lo que sucederá cuando alguien lo envíe. Crearemos un correo con el nombre, la dirección de respuesta y el mensaje. Durante las pruebas Laravel lo escribirá en su registro, de modo que podremos comprobar el contenido sin configurar un servidor SMTP.

Primero generaremos el Mailable y su plantilla mediante Artisan:

php artisan make:mail ContactMessage --view=mail.contact

Abre app/Mail/ContactMessage.php. Guardaremos los datos en el constructor y definiremos el asunto y la plantilla con la API de correo de Laravel:

<?php

namespace App\Mail;

use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;

class ContactMessage extends Mailable
{
    public function __construct(
        public string $senderName,
        public string $senderEmail,
        public string $messageBody,
    ) {}

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: 'Consulta desde la web',
            replyTo: [new Address($this->senderEmail, $this->senderName)],
        );
    }

    public function content(): Content
    {
        return new Content(text: 'mail.contact');
    }
}

La vista resources/views/mail/contact.blade.php mostrará los datos como texto, sin una plantilla HTML adicional:

Nombre: {{ $senderName }}
Email: {{ $senderEmail }}

{{ $messageBody }}

Guardaremos la dirección de destino en config/contact.php, para leer la variable del .env desde la configuración:

<?php

return ['recipient' => env('CONTACT_RECIPIENT', 'contacto@example.com')];

La dirección del usuario aparece en replyTo. El remitente seguirá siendo el de tu aplicación, que es quien realmente manda el correo.

Valida el formulario en Laravel

La comprobación importante tiene lugar en store. Aunque alguien se salte la página y haga una petición directamente, Laravel exigirá el token y lo verificará mediante CaptchaRule. El correo se envía después de completar esa validación.

Genera el controlador y sustituye su contenido por lo siguiente:

php artisan make:controller ContactController
<?php

namespace App\Http\Controllers;

use App\Mail\ContactMessage;
use EduLazaro\Laracaptcha\Facades\Captcha;
use EduLazaro\Laracaptcha\Rules\Captcha as CaptchaRule;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\View\View;

class ContactController extends Controller
{
    public function create(): View
    {
        return view('contact');
    }

    public function store(Request $request): RedirectResponse
    {
        $data = $request->validate([
            'name' => ['required', 'string', 'max:100'],
            'email' => ['required', 'email', 'max:254'],
            'message' => ['required', 'string', 'min:10', 'max:3000'],
            Captcha::responseField() => ['required', new CaptchaRule],
        ]);

        Mail::to(config('contact.recipient'))->send(new ContactMessage(
            $data['name'], $data['email'], $data['message'],
        ));

        return back()->with('status', 'Hemos recibido tu mensaje.');
    }
}

Define las rutas

El formulario tendrá una ruta para mostrar la página y otra para recibir los datos. Registraremos ambas en routes/web.php, con un límite de cinco envíos por minuto en la segunda:

<?php

use App\Http\Controllers\ContactController;
use Illuminate\Support\Facades\Route;

Route::get('/contacto', [ContactController::class, 'create'])->name('contact.create');
Route::post('/contacto', [ContactController::class, 'store'])
    ->middleware('throttle:5,1')->name('contact.send');

Crea la vista Blade

Ahora podemos preparar resources/views/contact.blade.php. El componente <x-laracaptcha::widget /> carga Turnstile y añade el campo del token al formulario. Usaremos old() para recuperar el nombre, el email y el mensaje si algún dato no pasa la validación:

<!doctype html>
<html lang="es">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Formulario de contacto</title>
    <link rel="stylesheet" href="{{ asset('contacto.css') }}">

</head>
<body>
<main>
    <p class="eyebrow">Turnstile · Laravel + Blade</p>
    <h1>Escríbenos</h1>
    <p>Envíanos tu consulta desde este formulario.</p>
    @if (session('status'))
        <p class="success" role="status">{{ session('status') }}</p>
    @endif
    <form method="post" action="{{ route('contact.send') }}">
        @csrf
        <label for="name">Nombre</label>
        <input id="name" name="name" value="{{ old('name') }}" required maxlength="100" autocomplete="name">
        @error('name') <p class="error">{{ $message }}</p> @enderror

        <label for="email">Email</label>
        <input id="email" name="email" type="email" value="{{ old('email') }}" required maxlength="254" autocomplete="email">
        @error('email') <p class="error">{{ $message }}</p> @enderror

        <label for="message">Mensaje</label>
        <textarea id="message" name="message" required minlength="10" maxlength="3000">{{ old('message') }}</textarea>
        @error('message') <p class="error">{{ $message }}</p> @enderror

        <div class="captcha">
            <x-laracaptcha::widget language="es" />
            @error(\EduLazaro\Laracaptcha\Facades\Captcha::responseField())
                <p class="error">{{ $message }}</p>
            @enderror
        </div>
        <button>Enviar mensaje</button>
    </form>
</main>
</body>
</html>

Captcha::responseField() devuelve el nombre del campo esperado por el proveedor; con Turnstile será cf-turnstile-response. Así evitamos escribir ese nombre tanto en el controlador como en el bloque de errores.

La directiva @csrf sigue siendo necesaria. Turnstile comprueba el desafío del visitante, mientras que Laravel utiliza el token CSRF para proteger el envío de este formulario. Al producirse una redirección se carga una página nueva y el widget obtiene otra respuesta.

Añade los estilos

Para darle un poco de forma, guarda estos estilos en public/contacto.css. El archivo se sirve directamente desde Laravel, por lo que en esta versión con Blade no necesitamos compilar JavaScript:

* { box-sizing: border-box; }
body { margin: 0; background: #f2f5f9; color: #172033; font: 16px/1.5 system-ui, sans-serif; }
main { width: min(560px, calc(100% - 32px)); margin: 40px auto; padding: 24px; background: white; border: 1px solid #dce3ed; border-radius: 16px; }
h1 { margin: 4px 0 12px; font-size: 28px; }
.eyebrow { color: #4255b5; font-weight: 700; font-size: 14px; }
label { display: block; margin: 16px 0 5px; font-weight: 600; }
input, textarea { width: 100%; padding: 10px; border: 1px solid #aab6c7; border-radius: 6px; font: inherit; }
textarea { min-height: 100px; resize: vertical; }
.captcha { margin: 20px 0; }
button { width: 100%; padding: 12px; color: white; background: #344caa; border: 0; border-radius: 7px; font: inherit; cursor: pointer; }
button:disabled { opacity: .6; cursor: wait; }
.error { color: #a31521; }
.success { padding: 10px; background: #e5f5eb; color: #195e35; }
@media (max-width: 420px) { main { width: calc(100% - 16px); padding: 12px; margin: 16px auto; } }

Prueba el formulario

Arranca la aplicación y abre http://127.0.0.1:8000/contacto para probarla. Completa los campos y espera a que Turnstile termine su comprobación antes de enviar. El resultado será un mensaje de confirmación y un correo escrito en storage/logs/laravel.log.

Para poner en marcha el ejemplo:

php artisan serve

El registro del correo se puede leer desde la terminal:

tail -f storage/logs/laravel.log

Prepara Turnstile para producción

Cuando registres el widget del dominio real, reemplaza las dos credenciales del .env. Si quieres identificar el envío con la acción contact, tendrás que declararla tanto en Blade como en la regla que verifica la petición.

El componente de Blade recibiría la acción de esta forma:

<x-laracaptcha::widget action="contact" language="es" />

La regla correspondiente en store sería:

CaptchaRule::make(action: 'contact')

Hazlo al utilizar las claves del widget real. Las credenciales públicas de pruebas no sirven para comprobar una acción personalizada de este formulario.

Revisa la configuración del servidor

En el servidor definitivo necesitarás tus claves del widget, HTTPS y un servicio de correo configurado. Para aceptar únicamente los dominios de tu aplicación, puedes añadir esta variable:

CAPTCHA_HOSTNAMES=ejemplo.com,www.ejemplo.com

Cambia los dominios del ejemplo por los tuyos al utilizar claves reales. La clave secreta permanecerá en el servidor. Cloudflare documenta la verificación obligatoria y la caducidad de cinco minutos; cada token se acepta una sola vez.

Para dejar de escribir correos en el log, configura MAIL_MAILER y las credenciales de tu servicio. Si más adelante utilizas colas, verifica primero el captcha y encola después el mensaje.

Comprueba la validación con tests

La prueba debe comprobar que una consulta autorizada llega al correo. Con Captcha::fake() podemos simular la verificación dentro del test y usar Captcha::fake(false) para ensayar el rechazo. Estos métodos se utilizan exclusivamente durante las pruebas.

Artisan nos permitirá crear una prueba de este envío:

php artisan make:test ContactFormTest --phpunit

Añade los imports al inicio del archivo generado y este método dentro de la clase del test:

use App\Mail\ContactMessage;
use EduLazaro\Laracaptcha\Facades\Captcha;
use Illuminate\Support\Facades\Mail;

public function test_contact_form_sends_a_verified_message(): void
{
    Captcha::fake();
    Mail::fake();

    $this->postJson('/contacto', [
        'name' => 'Edu',
        'email' => 'edu@example.com',
        'message' => 'Una consulta de prueba para el formulario.',
        Captcha::responseField() => 'test-token',
    ])->assertRedirect();

    Mail::assertSent(ContactMessage::class);
}

Ejecuta php artisan test --filter=ContactFormTest. Completa la cobertura intentando enviar sin token, con uno rechazado y con otro ya utilizado; el correo debe quedar bloqueado.

Finalizando

Ya tenemos Turnstile integrado en un formulario de Laravel con Blade. A partir de aquí puedes reutilizar la regla en otros formularios, cambiar el destinatario o guardar las consultas en tu base de datos. Mantén el límite de peticiones y la validación del servidor al hacer esas modificaciones.

Esto ha sido todo. ¡Espero que te resulte útil!