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

Turnstile Laravel Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.


Implementation Patterns

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.


Gotchas and Tips

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.

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.
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle
adachsoft/api-version-bundle
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager