coderflex/laravel-turnstile
Laravel Turnstile makes it easy to add Cloudflare Turnstile CAPTCHA to your Laravel app, with publishable config, env-based keys, customizable error messages, and a simple validation rule.
Install and wire up keys (5 minutes).
composer require coderflex/laravel-turnstile
php artisan vendor:publish --tag="turnstile-config"
Add the pair of keys from the Cloudflare dashboard to .env:
TURNSTILE_SITE_KEY=0x4AAAAAAA...
TURNSTILE_SECRET_KEY=0x4AAAAAAA...
Where to look first, in this order:
config/turnstile.php — the only two knobs that matter (turnstile_site_key, turnstile_secret_key) plus error_messages.turnstile_check_message.<x-turnstile-widget /> — it renders the Cloudflare script tag, the widget container, and the cf-turnstile-response hidden input inside your form.Coderflex\LaravelTurnstile\Rules\TurnstileCheck — the thing you will actually use 90% of the time.Coderflex\LaravelTurnstile\Facades\LaravelTurnstile — the escape hatch when you need the raw Cloudflare response.First use case: the contact form. Two lines, one in the view, one in the controller.
<form method="POST" action="{{ route('contact.store') }}">
@csrf
<input type="text" name="name" value="{{ old('name') }}">
@error('name') <p class="error">{{ $message }}</p> @enderror
<div>
<x-turnstile-widget theme="auto" language="en" />
@error('cf-turnstile-response') <p class="error">{{ $message }}</p> @enderror
</div>
<button>Send</button>
</form>
use Coderflex\LaravelTurnstile\Rules\TurnstileCheck;
public function store(Request $request)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:250'],
'cf-turnstile-response' => ['required', new TurnstileCheck()],
]);
// ...persist
}
That is the whole happy path. Everything below is what you do on day 2 through day 200.
Before you commit: check the version constraint. v2.1.1 dropped Laravel 10 and PHP 8.1, and v2.2.x adds Laravel 12/13 and PHP 8.4 support. If the app is still on Laravel 10, pin ^2.1.0 and note the constraint in the PR description so the next composer update doesn't surprise anyone.
Default to the validation rule, not the facade. The rule gives you free $errors bag integration, old() repopulation, and one consistent error key. The facade only earns its place when you need to branch on why the challenge failed.
Group-level protection for auth routes. Rather than editing five controllers, wrap the risky routes:
// app/Http/Middleware/EnsureTurnstilePasses.php
public function handle(Request $request, Closure $next)
{
$result = LaravelTurnstile::validate($request->input('cf-turnstile-response'));
if (! ($result['success'] ?? false)) {
Log::warning('turnstile.failed', ['codes' => $result['error_codes'] ?? []]);
throw ValidationException::withMessages([
'cf-turnstile-response' => config('turnstile.error_messages.turnstile_check_message'),
]);
}
return $next($request);
}
Route::middleware(['guest', 'turnstile'])->group(function () {
Route::post('/register', [RegisterController::class, 'store']);
Route::post('/login', [LoginController::class, 'store']);
Route::post('/password/email', [ForgotPasswordController::class, 'send']);
});
Apply it to POST only. Never put it on a GET route group — the token doesn't exist yet and every page load will throw.
Form Requests keep controllers clean. The rule composes normally:
class StoreRegistrationRequest extends FormRequest
{
public function rules(): array
{
return [
'email' => ['required', 'email', 'unique:users'],
'password' => ['required', 'confirmed', Password::defaults()],
'cf-turnstile-response' => ['required', new TurnstileCheck()],
];
}
}
Wrap the widget in your own component so theme/language/appearance live in one place and the widget can't drift between forms:
{{-- resources/views/components/ui/captcha.blade.php --}}
<div {{ $attributes->merge(['class' => 'my-4']) }}>
<x-turnstile-widget
theme="auto"
language="{{ app()->getLocale() }}"
size="flexible"
:callback="$callback ?? null"
/>
@error('cf-turnstile-response') <p class="text-sm text-red-600">{{ $message }}</p> @enderror
</div>
Testing is the part people underestimate. Cloudflare publishes dummy keys for exactly this; wire them into phpunit.xml so tests never hit the network:
<env name="TURNSTILE_SITE_KEY" value="1x00000000000000000000AA"/>
<env name="TURNSTILE_SECRET_KEY" value="1x0000000000000000000000000000000AA"/>
1x… site key + 1x… secret always passes; 2x… secret always fails; 3x… secret simulates an already-spent token. For unit-level tests, fake the HTTP call by inspecting the request the package makes during a local run and replicating it:
Http::fake([
'challenges.cloudflare.com/*' => Http::response(['success' => true, 'error-codes' => []], 200),
]);
$this->post('/contact', ['name' => 'Ada', 'cf-turnstile-response' => 'XXXX.DUMMY.TOKEN.XXXX'])
->assertRedirect();
If you would rather not assert against the real HTTP boundary, mock the facade instead:
LaravelTurnstile::shouldReceive('validate')->once()->andReturn(['success' => true]);
Feature-flag it. Because the keys come from env(), TURNSTILE_SITE_KEY=null is your kill switch for local work and for a bad rollout — but see the config-cache gotcha below before you rely on flipping it in production.
A token is single-use and expires in ~300 seconds. This is the #1 source of "it worked in staging, not in prod" bugs. Two consequences: validation must happen on the same request that rendered the form, and a user who leaves a tab open for six minutes gets a failure on submit. Always re-render the widget after a failed validation so the user can retry — the stale token will fail forever.
Never validate twice in one request. This looks harmless and isn't:
$request->validate([..., 'cf-turnstile-response' => [new TurnstileCheck()]]); // token spent
$res = LaravelTurnstile::validate(); // → error code timeout-or-duplicate
Pick one path per request. The README shows both as alternatives with an "or" between them for a reason.
Config caching will bite you. config/turnstile.php reads env() directly, which means php artisan config:cache bakes the keys in. Changing .env on a running server does nothing until you run php artisan config:cache again. Deploy scripts that clear config but don't re-cache, or that cache before env is present, produce missing-input-secret errors from Cloudflare with no local symptom. Add php artisan config:cache to the deploy and verify the site key appears in the rendered HTML after each deploy.
Content-Security-Policy breaks the widget silently. Turnstile loads a script from https://challenges.cloudflare.com and renders in an iframe from the same origin. If you ship a strict CSP you need both:
script-src ... https://challenges.cloudflare.com;
frame-src ... https://challenges.cloudflare.com;
Symptom when you forget: an empty grey box, no JS console error pointing at your policy, and a form that always fails server-side. If you use spatie/laravel-csp, add these to the policy class rather than the nginx config so they're testable.
The error key is the input name. @error('cf-turnstile-response') — not turnstile, not captcha. If you render the error under the wrong key, the user sees a form that fails with no visible reason. Keep the key identical in the Blade, the FormRequest, and the middleware.
Livewire re-renders invalidate the token. Any component update that touches the form markup can detach the widget. Wrap the CAPTCHA in wire:ignore and re-render the widget manually after a failed submit. In Alpine/Inertia SPAs, call turnstile.reset() on the container after a validation error — otherwise the second attempt posts the same spent token.
Multiple widgets on one page need separate <form> elements. The widget injects a hidden input literally named cf-turnstile-response; two widgets in the same form produce two inputs with that name and a $_POST value of the last one. One form, one widget. Multiple forms across a page is fine.
Watch out for outbound network/firewall rules. Validation is a server-to-server HTTPS call to challenges.cloudflare.com. In a VPC with egress restrictions, on a CI runner, or behind a corporate proxy, the package returns a failure and your tests go red for unrelated reasons. If egress is a real constraint, wrap the call in a try/catch and decide explicitly whether to fail open (log and allow) or fail closed (reject). Fail-closed is the right default for registration; fail-open is defensible for a low-risk contact form during a Cloudflare outage.
Debug with the error codes, not the message. The response array carries Cloudflare's error_codes. invalid-input-response means the token was malformed or expired; timeout-or-duplicate means double submission or a 300s+ stale token; missing-input-secret / invalid-input-secret mean env/config cache; invalid-input-response on every request usually means the widget rendered but the token never reached the server (CSP or a JS error). Log the array once during integration, then keep the codes in your log channel — that's your only signal from a headless browser.
Don't prefix the secret with VITE_. Ever. TURNSTILE_SECRET_KEY must stay server-side only. The site key is public by design and safe to render in HTML.
Publishing the views is the extension point. Some Cloudflare parameters (appearance, action, cdata, retry-interval) aren't all exposed as component props. php artisan vendor:publish --tag="turnstile-views" and add data-* attributes to the container — that's the sanctioned way to customise, and it survives composer update, unlike editing vendor/.
action and cdata are worth adding for diagnostics. Setting data-action="register" on the widget lets you tell which form a failure came from in the siteverify response — useful once you have more than one protected form and are triaging a spike in failures.
Package name vs repo name. The repo lives at coderflexx/laravel-turnstile; the Composer package is coderflex/laravel-turnstile (one x). Copy-pasting the GitHub URL into composer require fails. Also worth knowing: dependents count is near zero and the changelog is dominated by Dependabot bumps, so it's a small, single-maintainer package. It's MIT and ~200 lines of glue — if it ever stalls, forking the Blade component plus a 30-line Http::asForm()->post() to the siteverify endpoint is a realistic afternoon, so don't build architecture that depends on it staying published.
Localisation is free but manual. The configured message passes through the translator, so drop a lang/{locale}/turnstile.php with the turnstile_check_message key rather than hardcoding translations in config. The message is user-facing — phrase it as "please refresh the page and try again," not "the CAPTCHA thinks you are a robot," which reads as an accusation in a support inbox.
How can I help you explore Laravel packages today?