Adding Cloudflare Turnstile to Livewire Forms
Google's reCAPTCHA works, but it tracks users, shows annoying puzzles, and requires a Google account. Cloudflare Turnstile is a free alternative that verifies visitors invisibly, respects their privacy, and takes minutes to integrate. Here's how to add it to your Livewire forms from scratch.
Get your Turnstile keys
Head to the Cloudflare dashboard, navigate to Turnstile, and add your site. You will get a site key (public, used in the browser) and a secret key (private, used for server-side verification). Add both to your .env file:
TURNSTILE_SITE_KEY=your-site-key
TURNSTILE_SECRET_KEY=your-secret-key
Configuration
Create a config file that reads both keys from the environment. A nice side effect of this approach: when the keys are null (no .env values set), validation will pass and the widget will not render. This makes local development seamless — no Turnstile widget cluttering your forms unless you explicitly configure it.
<?php
// config/turnstile.php
return [
'site_key' => env('TURNSTILE_SITE_KEY'),
'secret_key' => env('TURNSTILE_SECRET_KEY'),
];
The validation rule
Build a custom validation rule that posts the user's token to Cloudflare's siteverify endpoint. The early return when no secret is configured is what makes the graceful skip work.
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Http;
class ValidTurnstileToken implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$secretKey = config('turnstile.secret_key');
if (empty($secretKey)) {
return;
}
$response = Http::asForm()->post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
'secret' => $secretKey,
'response' => $value,
'remoteip' => request()->ip(),
]);
if (! $response->json('success')) {
$fail('Security verification failed. Please try again.');
}
}
}
Blade components
Script tag
Create a Blade component that loads Cloudflare's Turnstile script. The render=explicit parameter prevents the widget from auto-rendering — we'll control that ourselves. Place this in your layout's <head>.
{{-- resources/views/components/turnstile/scripts.blade.php --}}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" defer></script>
Widget
The widget component does a few things:
- Accepts a
wireModelprop (defaults toturnstileToken) so you can reuse it across forms - Only renders when a site key is configured
- Uses Alpine.js
x-initto callturnstile.render()with explicit mode - Binds the resulting token back to the Livewire component via
$wire.set() - Detects dark mode from the
darkclass on<html> - Uses
wire:ignoreto prevent Livewire from clobbering the Turnstile iframe on re-renders
{{-- resources/views/components/turnstile/widget.blade.php --}}
@props(['wireModel' => 'turnstileToken'])
@if(config('turnstile.site_key'))
<div x-data x-init="
if (typeof turnstile !== 'undefined') {
turnstile.render($refs.turnstile, {
sitekey: '{{ config('turnstile.site_key') }}',
callback: (token) => $wire.set('{{ $wireModel }}', token),
'expired-callback': () => $wire.set('{{ $wireModel }}', ''),
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light',
});
}
">
<div wire:ignore x-ref="turnstile"></div>
</div>
@endif
Wiring it up in Livewire
Add a public property for the token and validate it in your submit method. The array_filter pattern makes the required rule conditional — it's only enforced when a secret key is configured. After submission (success or failure), reset the token so the widget can generate a fresh one.
use Pulli\LaravelTurnstile\Rules\ValidTurnstileToken;
public string $turnstileToken = '';
public function submit(): void
{
$this->validate([
'turnstileToken' => array_filter([
config('turnstile.secret_key') ? 'required' : null,
new ValidTurnstileToken,
]),
]);
// ... your form logic ...
$this->reset('turnstileToken');
}
In your Blade view, drop the widget and error message right before the submit button:
<x-turnstile::widget />
<flux:error name="turnstileToken" />
<flux:button type="submit" variant="primary">Submit</flux:button>
Testing
Because the validation rule skips verification when no secret is configured, the happy path in your test suite just works — no HTTP fakes needed.
To test failure scenarios, set the config value and fake the Cloudflare response:
// Happy path — no secret configured, validation passes
it('can submit the form', function () {
Livewire::test(ContactForm::class)
->set('name', 'Jane Doe')
->set('email', 'jane@example.com')
->set('message', 'Hello!')
->call('submit')
->assertHasNoErrors('turnstileToken');
});
// Failure — secret configured, Cloudflare rejects the token
it('rejects submission when turnstile verification fails', function () {
config(['turnstile.secret_key' => 'test-secret-key']);
Http::fake([
'challenges.cloudflare.com/turnstile/v0/siteverify' => Http::response([
'success' => false,
]),
]);
Livewire::test(ContactForm::class)
->set('name', 'Jane Doe')
->set('email', 'jane@example.com')
->set('message', 'Hello!')
->set('turnstileToken', 'invalid-token')
->call('submit')
->assertHasErrors('turnstileToken');
});
// Empty token with secret configured — required rule kicks in
it('rejects submission when token is empty and secret is configured', function () {
config(['turnstile.secret_key' => 'test-secret-key']);
Livewire::test(ContactForm::class)
->set('name', 'Jane Doe')
->set('email', 'jane@example.com')
->set('message', 'Hello!')
->call('submit')
->assertHasErrors('turnstileToken');
});
Statamic tip — Antlers and Blade components
If you use Statamic with Antlers templates, you will notice that Antlers does not process Blade component tags like <x-turnstile::scripts />. The workaround is simple: create a Blade partial that renders the component, then include it from Antlers.
The Blade partial:
{{-- resources/views/partials/turnstile-scripts.blade.php --}}
<x-turnstile::scripts />
Then in your Antlers layout:
{{ partial:turnstile-scripts }}
All the code above is available as a ready-made Laravel package: pulli/laravel-turnstile. Require it via Composer, publish the config, and you're done.