Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Turnstile Laravel Package

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.

Deep Wiki
Context7

Getting Started

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

  1. config/turnstile.php — every knob the package exposes.
  2. src/Rules/ — the validation rule class (see note below on the exact FQCN).
  3. 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\Turnstile and Rules\TurnstileRule in different releases — run ls vendor/njoguamos/laravel-turnstile/src/Rules after 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).

Implementation Patterns

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.

Gotchas and Tips

  • Tokens are single-use and expire in ~300 seconds. A user who idles on the form, or who fails server-side validation and resubmits, will hit timeout-or-duplicate. The widget must be reset/re-rendered after a failed submission — otherwise every retry fails validation.
  • Never reuse a token across two forms on one page. One token, one verify call. Pages with a login and a newsletter form need two independent widgets.
  • The rule performs a synchronous outbound HTTP request. That's 100–300 ms added to every protected submit, and it will fail closed (or hang) if Cloudflare is unreachable. Consider wrapping the rule or setting a timeout on the underlying HTTP client in production.
  • Config caching. After deploying, 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."
  • CSP. Turnstile needs https://challenges.cloudflare.com in script-src, frame-src, and connect-src. Forgetting frame-src gives a blank widget with no error.
  • Domain mismatch (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.
  • Decode the error codes before guessing: 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.
  • Secret key leakage. The secret only ever belongs server-side. Don't put it behind VITE_/MIX_ prefixes and don't render config('turnstile.secret_key') into Blade.
  • Extension points are thin. You can publish and override the Blade view, and you can add attributes to <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.
  • Small package, small surface. At ~23 stars it's a thin wrapper over one endpoint — budget an hour to read 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core
nodisrupt/laravel-agent
sierratecnologia/media-manager