njoguamos/laravel-turnstile
A Laravel package for Cloudflare Turnstile, the privacy-friendly CAPTCHA alternative. Render the widget with a Blade component, validate tokens server-side using a ready-made validation rule, and protect forms and APIs from bots without user friction.
Install and publish
composer require njoguamos/laravel-turnstile
php artisan vendor:publish --tag=turnstile-config
This drops a config/turnstile.php containing the site_key, secret_key, and widget defaults (theme, size, language, etc.). If the tag isn't registered in your version, publish with the provider:
php artisan vendor:publish --provider="Njoguamos\LaravelTurnstile\LaravelTurnstileServiceProvider"
Wire up keys — grab a site key + secret from Cloudflare Dashboard → Turnstile → Add site:
TURNSTILE_SITE_KEY=0x4AAAAAAA...
TURNSTILE_SECRET_KEY=0x4AAAAAAA...
Where to look first
config/turnstile.php — every knob the package exposes.src/Rules/ — the validation rule class (see note below on the exact FQCN).resources/views/ — the Blade widget, if you need to override markup.First use case — protect a contact or login form:
<form method="POST" action="{{ route('contact.store') }}">
@csrf
<input name="email" type="email" required>
<x-turnstile />
<button type="submit">Send</button>
</form>
// app/Http/Requests/StoreContactRequest.php
use Njoguamos\LaravelTurnstile\Rules\Turnstile as TurnstileRule;
public function rules(): array
{
return [
'email' => ['required', 'email'],
'cf-turnstile-response' => ['required', new TurnstileRule()],
];
}
The rule class name has shifted between
Rules\TurnstileandRules\TurnstileRulein different releases — runls vendor/njoguamos/laravel-turnstile/src/Rulesafter installing and use whatever is there. Some versions also register a string alias, letting you write'cf-turnstile-response' => 'required|turnstile'. Check the README matching your installed version rather than trusting a blog post.
The key detail: the widget always posts its token under the field name cf-turnstile-response, so the rule must be attached to that key (not to a field you invented).
Form Requests are the natural home. Keep the rule in a Form Request so controllers stay thin, and give it a human error message:
public function messages(): array
{
return ['cf-turnstile-response.required' => 'Please complete the CAPTCHA challenge.'];
}
Ad-hoc validation when you don't want a Form Request:
$data = Validator::make($request->all(), [
'message' => ['required', 'string', 'max:2000'],
'cf-turnstile-response' => ['required', new TurnstileRule()],
])->validate();
Livewire. Turnstile renders once on page load; Livewire re-renders will wipe or duplicate the widget. Wrap it in wire:ignore and reset it after every submit so a fresh token is minted:
<div wire:ignore>
<x-turnstile />
</div>
public function store()
{
$this->validate([...]);
// ...
$this->dispatch('turnstile-reset'); // or call turnstile.reset() in JS
}
SPAs / modals / Inertia. The widget mounts on DOM ready, so a form injected later (modal, Inertia navigation) needs explicit rendering. Load the script with ?render=explicit, then:
turnstile.render('#cf-widget', {
sitekey: document.querySelector('meta[name="turnstile-site-key"]').content,
callback: (token) => { document.getElementById('cf-token').value = token; },
'expired-callback': () => { document.getElementById('cf-token').value = ''; },
});
Keep a hidden <input type="hidden" name="cf-turnstile-response" id="cf-token"> in sync with the callback. Call turnstile.reset() after a failed submit and before re-opening a modal.
Testing. There is no first-class fake() helper, so stub the site-verify endpoint instead of hitting Cloudflare:
Http::fake([
'challenges.cloudflare.com/*' => Http::response(['success' => true], 200),
]);
$this->post('/contact', [...])->assertRedirect('/thanks');
Http::assertSent(fn ($req) => str_contains($req->url(), 'siteverify'));
For the failure path, return ['success' => false, 'error-codes' => ['invalid-input-response']] and assert a validation error on cf-turnstile-response. Alternatively use Cloudflare's always-passing test credentials (site key 1x00000000000000000000AA, secret 1x0000000000000000000000000000000AA) in .env.testing — but remember the rule still makes a real HTTP call unless you fake it.
Other frameworks. Anything that posts a real form works unchanged; for fetch/Axios, read the token from the hidden input and include it in the payload as cf-turnstile-response.
timeout-or-duplicate. The widget must be reset/re-rendered after a failed submission — otherwise every retry fails validation.php artisan config:cache is required or the env values won't be read; conversely, stale cached config is the #1 cause of "widget renders but always fails."https://challenges.cloudflare.com in script-src, frame-src, and connect-src. Forgetting frame-src gives a blank widget with no error.error-codes: ["invalid-input-response"] with a perfectly valid token) usually means the site key wasn't added for the current hostname. Add localhost/staging hostnames in the Cloudflare dashboard, or use the test keys.1xxxxx = configuration (bad secret, bad site key), 3xxxxx = client (bad token, timeout, duplicate). Log error-codes from the verify response — the package's validation message is intentionally generic.old() input. If you repopulate the form with old(), don't echo back cf-turnstile-response; a stale token in the DOM silently overrides the fresh one and produces duplicate-token errors.VITE_/MIX_ prefixes and don't render config('turnstile.secret_key') into Blade.<x-turnstile theme="dark" size="compact" />, but there's no event system for verify results. If you need auditing, wrap the rule: subclass it or write your own rule that posts to https://challenges.cloudflare.com/turnstile/v0/siteverify, and log the response there.src/ fully so you're not debugging blind when Cloudflare changes behaviour or the package stalls. Fallback is trivial: a Http::asForm()->post(...) in a custom rule.How can I help you explore Laravel packages today?