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

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. config/turnstile.php — the only two knobs that matter (turnstile_site_key, turnstile_secret_key) plus error_messages.turnstile_check_message.
  2. The Blade component <x-turnstile-widget /> — it renders the Cloudflare script tag, the widget container, and the cf-turnstile-response hidden input inside your form.
  3. Coderflex\LaravelTurnstile\Rules\TurnstileCheck — the thing you will actually use 90% of the time.
  4. 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.

Implementation Patterns

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.

Gotchas and Tips

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.

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.
steadrun/laravel-monitor
labyrinthe/connect-laravel
elmogy/layan
drewdan/sentinel-client
dskripchenko/laravel-llm
fatryst/nuonuo
stevenay/myanfont
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc
animalyoung/laravel-influxdb
onix-solutions/sms-telera
lonely-lights/prosetta
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin