derekcodes/turnstile-laravel
A Laravel package for integrating Cloudflare Turnstile, the privacy-friendly CAPTCHA alternative. Add Turnstile to forms and verify tokens server-side using Laravel validation, with configurable keys and simple Blade helpers for protecting submissions.
1. Install and publish config
composer require derekcodes/turnstile-laravel
php artisan vendor:publish --provider="Derekcodes\Turnstile\TurnstileServiceProvider"
This drops config/turnstile.php into your app. Read it first — it's the source of truth for the env var names and any tuning knobs (hostname allow-list, remoteip behaviour, etc.). If the exact provider class name differs, php artisan vendor:publish with no arguments and pick the numeric tag interactively.
2. Add keys to .env
TURNSTILE_SITE_KEY=0x4AAAAAAA... # public, rendered in HTML
TURNSTILE_SECRET_KEY=0x4AAAAAAA... # private, server-side only
Grab both from Cloudflare Dashboard → Turnstile → Add Site. Note the widget mode you pick there (Managed / Non-interactive / Invisible) — it determines how the widget looks and whether you need to render it manually.
3. Drop the widget into a form
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<form method="POST" action="{{ route('contact.store') }}">
@csrf
<input name="email" type="email">
<div class="cf-turnstile" data-sitekey="{{ config('turnstile.site_key') }}"></div>
<button type="submit">Send</button>
</form>
The package may also ship a Blade component (<x-turnstile />) or directive — check vendor/derekcodes/turnstile-laravel/resources/views and the service provider before writing the raw div.
4. Validate server-side
use Derekcodes\Turnstile\Rules\Turnstile;
$request->validate([
'cf-turnstile-response' => ['required', new Turnstile()],
]);
That's the first useful milestone: one public form, one rule, rejected on failure. Everything else builds on this.
Where to look first if something is off: the package source is small (57 stars, single-purpose). Read src/Turnstile.php and src/Rules/ — the HTTP call to https://challenges.cloudflare.com/turnstile/v0/siteverify and its response handling live there, and that tells you exactly what validate() returns and throws.
Form Request (preferred for anything non-trivial)
class StoreContactRequest extends FormRequest
{
public function rules(): array
{
return [
'email' => ['required', 'email'],
'cf-turnstile-response' => ['required', new \Derekcodes\Turnstile\Rules\Turnstile],
];
}
}
Because the validation happens at the edge of the request lifecycle, you never validate the token twice — which matters, since Turnstile tokens are single-use (see Gotchas).
Facade for non-form flows
Login, password reset, API endpoints, or anything you validate imperatively:
use Derekcodes\Turnstile\Facades\Turnstile;
if (! Turnstile::validate($request->input('cf-turnstile-response'))) {
throw ValidationException::withMessages([
'cf-turnstile-response' => 'Captcha verification failed.',
]);
}
Pass the user IP as the second argument if the package supports it — it's forwarded to Cloudflare as remoteip and tightens verification. Check the signature in src/Turnstile.php; it's usually validate(string $token, ?string $ip = null).
Custom messages and attribute names
$request->validate([
'cf-turnstile-response' => ['required', new Turnstile],
], [
'cf-turnstile-response.required' => 'Please complete the captcha.',
]);
Don't rename the input field — cf-turnstile-response is what Cloudflare's JS injects and what the package rule looks for by default.
Testing — fake the verify call
Never hit Cloudflare in tests. Fake the HTTP client in your test base class or per-test:
Http::fake([
'challenges.cloudflare.com/*' => Http::response([
'success' => true,
'challenge_ts' => now()->toIso8601String(),
'hostname' => 'example.test',
]),
]);
$this->post('/contact', [
'email' => '[email protected]',
'cf-turnstile-response' => 'dummy-token',
])->assertRedirect();
Then a sibling test faking ['success' => false, 'error-codes' => ['invalid-input-response']] for the rejection path. This is the single most important pattern — without it CI will either fail or silently hit the real endpoint.
Livewire / SPA / Inertia
The widget is a normal DOM element that Cloudflare renders on load. When your page re-renders without a full reload (Livewire navigation, Inertia visits, Turbo), the token is gone or stale. Reset it after each successful submit:
turnstile.reset('.cf-turnstile');
and re-render if needed. In Livewire specifically, wire the token into a public property and reset on reset events.
Multiple widgets on one page (e.g. login modal + signup form): give each a unique data-sitekey+data-action, render explicitly rather than relying on auto-render if you're behind a JS-heavy router:
<div class="cf-turnstile"
data-sitekey="{{ config('turnstile.site_key') }}"
data-action="signup"
data-callback="onTurnstileSuccess"></div>
Global protection / middleware
If the package ships middleware (check src/Http/Middleware), it's convenient for blanket protection on a route group — but prefer per-form validation with the rule. Middleware on a POST route will reject requests that don't have a token, including any programmatic callers (mobile app, webhook, API consumer). Only use it for browser-only, form-only routes.
Use Cloudflare's "action" for defense in depth
Set data-action="contact" in the widget and assert $response->action === 'contact' if you're doing manual verification. It prevents a token minted for one form being replayed against another.
Tokens are single-use and expire in 300 seconds. Do not call Turnstile::validate() twice for the same token — the second call returns timeout-or-duplicate and looks like a failure. This bites hardest in middleware + FormRequest combos where both validate. Pick one layer.
Don't confuse invalid-input-response with invalid-input-secret. The first means the user's token was bad (wrong key pair, stale widget, blocked script). The second means your TURNSTILE_SECRET_KEY is wrong — the config was cached with a null value, or you used the site key as the secret. Log error-codes from the Cloudflare response during debugging; the package likely discards them in the return value, so wrap it or read the source if you need the reason.
Config caching. After rotating keys, php artisan config:cache (and config:clear in dev) is mandatory. A stale config cache returning null secret is the #1 cause of "captcha stopped working after deploy."
Test keys. Cloudflare publishes fixed test keys (1x00000000000000000000AA = always passes, 2x... = always blocks). Use these in local/staging .env files where the widget actually renders, and Http::fake() in automated tests. Don't ship real keys to staging.
remoteip behind a proxy. If you pass $request->ip() and the app sits behind Cloudflare or a load balancer without TrustProxies configured, you'll forward the proxy IP and Cloudflare may flag a mismatch. Either configure trusted proxies properly or omit the IP — the token itself is the primary check.
Hostname validation. Cloudflare verifies the token was minted for a hostname matching your site config. localhost, 127.0.0.1, and staging domains will fail until you add them to the widget's allowed hostnames in the Cloudflare dashboard. Expect a broken widget the first time you deploy to a preview URL.
Widget not rendering? Check, in order: the api.js script tag is present and not defer-blocked by your bundler; the data-sitekey matches the site key exactly; the form isn't inside a container re-rendered by Vue/React after the script loads (use turnstile.render(element, options) explicitly in that case).
Pin the version. 57 stars and 0 dependents means you're an early adopter. "derekcodes/turnstile-laravel": "^1.0" is fine, but if you hit a bug, reading the vendor source and patching a fork is faster than waiting on an issue. The verify logic is a single HTTP POST — if the package becomes a maintenance risk, the whole thing is ~30 lines and can be replaced by a direct Http::asForm()->post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [...]) in a service class.
Extension point: since the package ultimately calls Http::..., any customisation (logging, retries, metrics) is easiest by wrapping the package's Turnstile service in your own class rather than editing vendor code — inject the package service via constructor and decorate it.
Rate-limit as defence in depth. Turnstile is not a substitute for throttling. Pair the rule with throttle:6,1 on the route; a solver that beats the captcha should still be limited.
How can I help you explore Laravel packages today?