usarise/turnstile
A Laravel package for integrating Cloudflare Turnstile into your forms, with easy setup, server-side validation, configurable keys, and Blade helpers to protect your app from bots and spam.
usarise/turnstile is a thin, framework-agnostic PHP client for Cloudflare's Turnstile siteverify endpoint — the server-side half of the CAPTCHA replacement. It gives you a typed request/response around the verification call; everything else (rendering the widget, wiring validation into Laravel) is on you.
1. Install
composer require usarise/turnstile
There is no service provider or auto-discovery, so nothing registers itself. Confirm the exact namespaces and method names against the README of the version you pinned — the library is small and the API surface is narrower than laravel-turnstile-style wrappers.
2. Collect your keys
From the Cloudflare dashboard (Turnstile → your site):
TURNSTILE_SITE_KEY=0x4AAAAAAA... # public, goes in HTML
TURNSTILE_SECRET_KEY=0x4AAAAAAA... # server only, never in JS
For local development and CI, use Cloudflare's documented test keys (1x00000000000000000000AA always passes, 2x00000000000000000000AB always fails, 3x00000000000000000000FF forces the interactive challenge). They work with any secret when you use the matching dummy secret — no network round-trip needed to reason about behaviour.
3. Render the widget
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<form method="POST" action="/contact">
@csrf
<div class="cf-turnstile" data-sitekey="{{ config('services.turnstile.site_key') }}"></div>
<button type="submit">Send</button>
</form>
The widget injects a hidden cf-turnstile-response input containing the token.
4. Verify server-side
use Usarise\Turnstile\Turnstile; // verify against your installed version
$turnstile = new Turnstile(config('services.turnstile.secret_key'));
$result = $turnstile->verify(
request('cf-turnstile-response'),
request()->ip(),
);
if (! $result->isSuccess()) {
// reject
}
Where to look first: the README's single usage example and the error-codes array on a failed response. That array is where 90% of your debugging time will go (invalid-input-response, timeout-or-duplicate, invalid-input-secret).
First use case: a public contact form. It's the lowest-risk integration — no auth state to protect, no redirect flow — and it forces you to solve the three things you'll reuse everywhere: key config, widget rendering, and a validation rule.
Because the package is framework-agnostic, wrap it in a singleton so you're not reconstructing the client per request and you have one place to swap in a fake during tests.
// app/Providers/AppServiceProvider.php
$this->app->singleton(Turnstile::class, fn () => new Turnstile(
config('services.turnstile.secret_key')
));
// config/services.php
'turnstile' => [
'site_key' => env('TURNSTILE_SITE_KEY'),
'secret_key' => env('TURNSTILE_SECRET_KEY'),
'timeout' => env('TURNSTILE_TIMEOUT', 5),
'verify_ip' => env('TURNSTILE_VERIFY_IP', true),
],
This is the pattern that pays for itself immediately — one rule, applied to every form, instead of ad-hoc checks scattered across controllers.
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Log;
use Usarise\Turnstile\Turnstile;
class TurnstileRule implements ValidationRule
{
public function __construct(
private ?string $expectedAction = null,
) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (blank($value)) {
$fail('Please complete the verification challenge.');
return;
}
$result = app(Turnstile::class)->verify($value, request()->ip());
if (! $result->isSuccess()) {
Log::info('turnstile.rejected', [
'codes' => $result->getErrorCodes(), // adjust to real accessor
'ip' => request()->ip(),
]);
$fail('Verification failed. Please try again.');
return;
}
// Defence in depth: verify the response came from the widget you expect.
if ($this->expectedAction && $result->getAction() !== $this->expectedAction) {
$fail('Verification failed. Please try again.');
}
}
}
$request->validate([
'email' => ['required', 'email'],
'cf-turnstile-response' => ['required', new TurnstileRule('contact_form')],
]);
Set data-action="contact_form" and data-cdata on the widget so the server can confirm the token wasn't harvested from a different form on your site.
For routes where you want protection without polluting validation — registration, password reset, guest checkout — a middleware is cleaner than a rule:
public function handle(Request $request, Closure $next): Response
{
$token = $request->input('cf-turnstile-response');
if (blank($token) || ! app(Turnstile::class)->verify($token, $request->ip())->isSuccess()) {
throw ValidationException::withMessages([
'cf-turnstile-response' => 'Verification failed. Please try again.',
]);
}
return $next($request);
}
Combine with throttle: rather than replacing it. Turnstile stops bots at the door; rate limiting stops everything else.
Turnstile tokens are single-use. If your own request retries (job retry, mobile network flake, double-click), the second verification returns timeout-or-duplicate even though the human did nothing wrong. Pass a stable idempotency_key per user submission — Cloudflare will replay the first result instead of failing.
The single biggest source of "it works once then breaks" bugs. After a failed validation round-trip, the token in the page is spent. Reset the widget:
// Vanilla
turnstile.reset();
// Alpine / Livewire
document.addEventListener('livewire:init', () => {
Livewire.hook('morph.updated', () => turnstile.reset());
});
// Vue 3 / Inertia
onError(() => window.turnstile?.reset());
For SPAs, use explicit rendering (data-render="explicit" or turnstile.render(el, {...})) so you control the lifecycle — implicit rendering plus virtual DOM diffing is a reliable way to end up with two widgets or none.
Never hit Cloudflare in your test suite. Two options, in order of preference:
TurnstileVerifier) that returns a TurnstileResult value object, bind the real implementation to the package and a FakeTurnstileVerifier in tests. This also decouples you from the package's API, which matters given how small it is.Http::fake() the siteverify endpoint. This works if the package uses the Laravel HTTP client — it doesn't, so use Guzzle mock handlers or a fake HTTP client injected into the package if the constructor allows it.Then:
$this->mock(TurnstileVerifier::class)->shouldReceive('verify')->andReturn(TurnstileResult::success());
Tokens expire in 300 seconds and are single-use. Any flow where the user fills out a long form — support ticket, checkout, job application — will hit this. Either render the widget late (lazy render near the submit button) or handle timeout-or-duplicate by resetting the widget and asking the user to try once more, rather than screaming about bots.
timeout-or-duplicate is the ambiguous one. You cannot distinguish "expired" from "replayed." Don't treat it as an attack signal in logs; treat it as a UX event. Genuine replay abuse shows up as volume, not as a single code.
Always verify server-side; never trust the client. The success value you'd read in JS is only meaningful if your server independently calls siteverify. The token is just an opaque string to your JS.
Check hostname and action. By default siteverify returns success for any token minted with your site key — including one harvested from another page on your domain. If a form is sensitive, assert action matches and, if you run multiple hostnames, assert hostname is one you own.
Decide fail-open vs fail-closed explicitly. If Cloudflare is unreachable (timeout, DNS, 5xx), what happens? For a newsletter signup, fail open and log. For login, fail closed. Write it down in the config (TURNSTILE_STRICT), because the library will surface a network exception and the default behaviour of your code will be whatever the first developer wrote.
Set a short HTTP timeout. 3–5 seconds. Cloudflare's verify call is normally <200ms; anything longer means trouble, and a 30-second default Guzzle timeout will tie up PHP-FPM workers during an incident.
CSP will bite you. You need:
script-src https://challenges.cloudflare.com;
frame-src https://challenges.cloudflare.com;
connect-src https://challenges.cloudflare.com;
Miss frame-src and the widget renders as a blank box with no error message.
Site key vs secret key confusion. The site key is public by design — putting it in view source is correct. The secret key in NEXT_PUBLIC_* or a Vite VITE_* variable is a breach; it lets anyone mint valid tokens for your site.
Multiple widgets on one page need distinct containers. Implicit rendering targets .cf-turnstile elements and works fine, but if you're dynamically adding forms (modals, Livewire partials) you'll need data-render="explicit" plus manual turnstile.render() and matching turnstile.remove() on teardown, or you'll leak widgets.
Invisible mode still needs a fallback. If the challenge can't run (old browser, aggressive blocker, privacy extension), the token never appears and your required rule fires with a confusing message. Detect turnstile being undefined in JS and either surface a clear "disable your blocker" message or route to a fallback (email verification, manual review).
Pin the version and wrap it. 23 stars and zero dependents means no downstream pressure to keep the API stable and no community to lean on when it breaks. Two mitigations: composer require usarise/turnstile:^x.y with a committed lockfile, and your own TurnstileVerifier interface in front of it. If the package stalls, swapping to a direct Guzzle call is a 20-line change behind that interface.
Log the raw error codes for a week after launch. Aggregate them — a spike in invalid-input-secret means a config drift between environments; a spike in invalid-input-response usually means the widget never rendered; bad-request usually means malformed parameters on your side. The package's own exceptions won't tell you which.
How can I help you explore Laravel packages today?