romanzipp/laravel-turnstile
Laravel package for integrating Cloudflare Turnstile into your forms. Includes validation rules, middleware, and a Blade component to protect against bots and spam with a privacy-friendly captcha alternative to reCAPTCHA.
Install and publish config
composer require romanzipp/laravel-turnstile
php artisan vendor:publish --provider="romanzipp\Turnstile\Providers\TurnstileServiceProvider"
Auto-discovery registers the provider; the published config/turnstile.php is the first file to read — it exposes enabled, site_key, secret_key, and the HTTP client binding.
Configure keys
TURNSTILE_ENABLED=true
TURNSTILE_SITE_KEY=0x4AAAAAAA...
TURNSTILE_SECRET_KEY=0x4AAAAAAA...
For local/tests, use Cloudflare's published dummy keys (1x00000000000000000000AA / 1x0000000000000000000000000000000AA always pass) instead of disabling the feature, so you exercise the real code path.
First use case: protect a public form
Blade:
<form method="POST" action="{{ route('contact.store') }}">
@csrf
<x-turnstile::widget />
<button type="submit">Send</button>
</form>
Controller — the widget injects a cf-turnstile-response field, validated with the package rule:
use romanzipp\Turnstile\Rules\Turnstile;
$request->validate([
'cf-turnstile-response' => ['required', new Turnstile()],
]);
If that passes, the token was verified server-side against Cloudflare. Nothing else is required for the basic flow.
Route-level protection. The package ships a middleware for protecting whole endpoints (login, register, password reset). Alias it in app/Http/Kernel.php (Laravel ≤10) or bootstrap/app.php (Laravel 11+) and attach it to the POST route — not the GET route that renders the form, since there is no token on the initial page load:
Route::post('/login', [LoginController::class, 'store'])
->middleware('turnstile');
Manual verification outside form requests. For APIs, Inertia, or Livewire, resolve the Turnstile service from the container and pass the token explicitly rather than relying on the request key:
use romanzipp\Turnstile\Turnstile;
if (! app(Turnstile::class)->verify($request->input('turnstile_token'))) {
abort(422, 'Captcha verification failed.');
}
This is the pattern to use when the token arrives nested in JSON, in a custom field name, or after a Livewire round-trip.
Livewire. Render the widget inside the component and wire:ignore its container so Livewire's DOM diffing does not destroy Cloudflare's iframe. Expose the token as a public property via the widget's callback, then reset the widget after every submit attempt — including successful ones, since the token is single-use.
Inertia/Vue/React. Don't rely on the Blade component. Load https://challenges.cloudflare.com/turnstile/v0/api.js yourself, render the widget with turnstile.render(), and post the callback token in your form payload. Keep the server-side check identical to the Laravel-validated path so the rule stays in one place.
Testing. Set TURNSTILE_ENABLED=false in phpunit.xml only if you accept that the bypass branch is exercised instead of the real rule. Otherwise use the always-pass dummy secret and Http::fake() the siteverify endpoint to assert request/response handling — this catches regressions like a missing token field.
Customization. Widget options (theme, size, appearance, language) are passed through as Blade attributes; wrapper components around <x-turnstile::widget /> are the cleanest way to standardize styling across forms.
verify() call matters; never trust a hidden field the browser could forge.TURNSTILE_ENABLED=false disables verification entirely. It's convenient locally, but guard it: ensure the env var can't be flipped in production, and add a test asserting the rule is active when enabled.php artisan config:clear; a cached config with the old secret produces opaque "invalid input" errors from Cloudflare.https://challenges.cloudflare.com for script-src, frame-src, and connect-src, or the widget silently fails to render.verify().invalid-input-secret, timeout-or-duplicate, invalid-input-response). Log the raw siteverify response in non-production or wrap the rule to surface the code.How can I help you explore Laravel packages today?