En este tutorial vamos a ver cómo utilizar Turnstile con Laravel y Livewire en un formulario que se envía sin recargar la página. Nos apoyaremos en Laracaptcha para conectar el widget de Cloudflare con una propiedad del componente y validar su respuesta desde PHP.

Prepararemos un formulario de contacto con sus errores, un límite de intentos y una confirmación al terminar. Prestaremos especial atención a la renovación del token, ya que el componente permanece en la página después de cada envío.

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, Livewire y Turnstile

Instala las dependencias

Livewire nos permitirá mantener el estado del formulario en una clase PHP. Por su parte, Laracaptcha incluye el trait WithCaptcha, que valida el token y avisa al widget cuando debe reiniciarse.

Usaremos Laravel 13 y Livewire 4 con un componente de clase. Si comienzas desde cero, prepara la aplicación de este modo:

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

Configura Turnstile

Para probar el flujo en local basta con el par de credenciales de desarrollo de Turnstile. Configuraremos el proveedor y sus dos claves en .env, dejando las credenciales del dominio definitivo para el despliegue:

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

La acción del componente enviará una consulta por correo cuando todos los datos sean válidos. Vamos a crear un Mailable sencillo y a usar el registro de Laravel como buzón de desarrollo. Así la prueba completa funcionará aunque todavía no tengas contratado un servicio de correo.

Pide a Artisan que prepare los archivos del correo que utilizará la acción:

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

El archivo app/Mail/ContactMessage.php recibirá los valores validados por el componente de Livewire. Podemos preparar la clase con estas propiedades y métodos:

<?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');
    }
}

Vamos ahora a resources/views/mail/contact.blade.php, donde colocaremos las variables que recibe el correo:

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

{{ $messageBody }}

Conviene que Livewire obtenga el destinatario desde la configuración. Prepara config/contact.php así:

<?php

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

Observa que el remitente se configura en Laravel y la dirección de respuesta procede del formulario. Esto evita mezclar ambas identidades al enviar.

Crea el componente de Livewire

El comando de Livewire genera la clase y su vista por separado. Abre app/Livewire/ContactForm.php y prepara las propiedades que usará el formulario. El trait aporta la propiedad captcha, así que no hace falta declararla otra vez:

php artisan make:livewire ContactForm --class
<?php

namespace App\Livewire;

use App\Mail\ContactMessage;
use EduLazaro\Laracaptcha\Livewire\WithCaptcha;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\View\View;
use Livewire\Component;

class ContactForm extends Component
{
    use WithCaptcha;

    public string $name = '';
    public string $email = '';
    public string $message = '';
    public string $notice = '';

    public function send(): void
    {
        $this->resetErrorBag();
        $this->notice = '';
        $key = 'contact:'.request()->ip();

        if (RateLimiter::tooManyAttempts($key, 5)) {
            $this->addError('form', 'Demasiados intentos. Espera un minuto antes de repetir.');
            return;
        }

        RateLimiter::hit($key, 60);

        $data = $this->validate([
            'name' => ['required', 'string', 'max:100'],
            'email' => ['required', 'email', 'max:254'],
            'message' => ['required', 'string', 'min:10', 'max:3000'],
        ]);

        $this->verifyCaptcha();

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

        $this->reset('name', 'email', 'message');
        $this->notice = 'Hemos recibido tu mensaje.';
    }

    public function render(): View
    {
        return view('livewire.contact-form');
    }
}

Define las rutas

Para visitar el componente crearemos una página Blade normal. La ruta solo devuelve esa vista; las siguientes interacciones viajarán por el endpoint de actualización de Livewire. En routes/web.php basta con esta definición:

<?php

use Illuminate\Support\Facades\Route;

Route::view('/contacto', 'contact')->name('contact.create');

Enlaza los campos y el captcha

En resources/views/livewire/contact-form.blade.php enlazaremos cada campo con su propiedad. wire:submit ejecutará send, y el widget escribirá su token en captcha mediante wire:model:

<main>
    <p class="eyebrow">Turnstile · Laravel + Livewire</p>
    <h1>Escríbenos</h1>
    <p>Envíanos tu consulta desde este formulario.</p>
    @if ($notice)
        <p class="success" role="status">{{ $notice }}</p>
    @endif
    @error('form') <p class="error" role="alert">{{ $message }}</p> @enderror

    <form wire:submit="send">
        <label for="name">Nombre</label>
        <input id="name" wire:model="name" required maxlength="100" autocomplete="name">
        @error('name') <p class="error">{{ $message }}</p> @enderror

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

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

        <div class="captcha">
            <x-laracaptcha::widget wire:model="captcha" language="es" />
            @error('captcha') <p class="error">{{ $message }}</p> @enderror
        </div>
        <button wire:loading.attr="disabled" wire:target="send">Enviar mensaje</button>
    </form>
</main>

El límite está dentro de send() porque también debe aplicarse a las peticiones de actualización de Livewire. Colocarlo únicamente en la ruta que muestra la página dejaría fuera los siguientes envíos del componente.

verifyCaptcha() vacía el token y emite el evento de reinicio, tanto si la comprobación se acepta como si falla. De ese modo, al corregir un dato o mandar otra consulta el visitante dispone de un desafío nuevo. El botón se desactiva mientras se procesa la acción para evitar pulsaciones repetidas.

Muestra la página de contacto

El componente necesita una página contenedora. Crea resources/views/contact.blade.php para cargarlo junto a los estilos y los scripts de Livewire:

<!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') }}">
    @livewireStyles
</head>
<body>
<livewire:contact-form />
@livewireScripts
</body>
</html>

Añade los estilos

La página contenedora cargará una hoja de estilos normal. Añade a public/contacto.css estas reglas para los campos, el botón y los mensajes; no hace falta una compilación de frontend para este ejemplo:

* { 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

Abre /contacto después de iniciar Laravel. Prueba primero a enviar con algún campo incompleto y después con datos válidos. La confirmación debe aparecer dentro del componente y Turnstile tiene que volver a prepararse para el siguiente mensaje, sin recargar toda la página.

Arranca el servidor del formulario:

php artisan serve

Si quieres ver lo que genera el Mailable, consulta el log:

tail -f storage/logs/laravel.log

Prepara Turnstile para producción

En producción registra el widget con Cloudflare y utiliza sus claves. Puedes asignarle la acción contact al formulario de Livewire; la misma acción debe llegar al método de verificación del componente PHP.

En la vista del componente puedes indicarla en el widget:

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

Después, ajusta la llamada del trait en send():

$this->verifyCaptcha(action: 'contact');

Reserva esta configuración para tus claves reales. Con el par público de pruebas no puedes validar la acción personalizada de tu componente.

Revisa la configuración del servidor

Al desplegar el componente, cambia las credenciales de pruebas y configura HTTPS y el correo. El paquete permite además comprobar el dominio al que pertenece la respuesta del captcha:

CAPTCHA_HOSTNAMES=ejemplo.com,www.ejemplo.com

Indica aquí los dominios donde funciona tu formulario definitivo. Cloudflare exige la validación del servidor: las respuestas son de un solo uso y caducan a los cinco minutos. La clave privada no debe viajar al componente.

El transporte log sirve para desarrollo. Cambia MAIL_MAILER al publicar y configura su cuenta; un posible envío en cola debe quedar después de comprobar el captcha.

Comprueba la validación con tests

Para verificar la acción de Livewire utilizaremos Captcha::fake(). Esto evita las llamadas a Cloudflare dentro de la suite; Captcha::fake(false) permite revisar el caso fallido. Conserva la verificación real fuera de los tests.

Crea primero la clase del test para el componente:

php artisan make:test ContactFormTest --phpunit

En el archivo de prueba, coloca las importaciones con las demás y el método dentro de su clase:

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

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

    Livewire::test(ContactForm::class)
        ->set('name', 'Edu')
        ->set('email', 'edu@example.com')
        ->set('message', 'Una consulta de prueba para el formulario.')
        ->set('captcha', 'test-token')
        ->call('send')
        ->assertHasNoErrors()
        ->assertSet('captcha', '');

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

Con php artisan test --filter=ContactFormTest podrás ejecutar el caso. Revisa también la ausencia de captcha, el rechazo y un token consumido: ninguno debe producir correos.

Finalizando

Ya puedes proteger una acción de Livewire con Turnstile conservando el estado y los errores del formulario. Si amplías el componente para guardar solicitudes o adjuntar archivos, coloca esas operaciones después de la validación y de verifyCaptcha(). También puedes cambiar el límite de intentos según el uso real de la página.

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