Search by

kalprajsolutions / laravel-turnstile

kalprajsolutions

A Laravel Blade component for Cloudflare Turnstile CAPTCHA integration

Package info

github.com/kalprajsolutions/laravel-turnstile

Language:Blade

pkg:composer/kalprajsolutions/laravel-turnstile

Statistics

Installs: 13

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

1.0.0 2026-03-11 17:33 UTC

This package is auto-updated.

Last update: 2026-08-25 04:00:18 UTC


README

Logo for Laravel Wordpress CDA # Laravel Turnstile

License Total Downloads Latest Version Laravel

A Laravel package for easy integration of Cloudflare Turnstile CAPTCHA into your forms. Provides a simple Blade component with both standard and lazy loading modes for optimal user experience.

Description

Laravel Turnstile provides a seamless way to integrate Cloudflare's Turnstile CAPTCHA into your Laravel applications. Unlike traditional CAPTCHAs, Turnstile is user-friendly and doesn't require users to solve puzzles, making it invisible to most legitimate users while still protecting your forms from bots.

Features

  • Easy Integration - Simple Blade component to add Turnstile to any form
  • Standard Mode - Widget loads immediately, buttons disabled until verification
  • Lazy Mode - Widget appears only when user attempts to submit
  • Multiple Widgets - Support for multiple Turnstile widgets on the same page
  • Livewire Support - Built-in integration with Livewire components
  • Server-side Validation - Multiple ways to validate the Turnstile response

Installation

Install the package via Composer:

composer require kalprajsolutions/laravel-turnstile

The package will automatically register its service provider. If you're using Laravel 10 or below, you may need to manually add the service provider to your config/app.php.

Publish Component (Optional)

If you need to customize the configuration, publish the view file:

php artisan vendor:publish --provider="KalprajSolutions\LaravelTurnstile\TurnstileServiceProvider"

Configuration

Environment Variables

Add your Cloudflare Turnstile credentials to your .env file:

CLOUDFLARE_SITE_KEY=your_site_key_here
CLOUDFLARE_SECRET_KEY=your_secret_key_here

Services Configuration

The package uses Laravel's config/services.php structure. Add the following to your config/services.php file:

'cloudflare' => [
    'site_key' => env('CLOUDFLARE_SITE_KEY'),
    'secret_key' => env('CLOUDFLARE_SECRET_KEY'),
    'endpoint' => 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
],

Getting Your Cloudflare Credentials

  1. Go to Cloudflare Dashboard
  2. Navigate to Turnstile under the Security section
  3. Create a new site and get your Site Key and Secret Key
  4. Make sure to add your domain to the allowed domains list

Usage

Basic Usage (Standard Mode)

In standard mode, the Turnstile widget loads immediately when the page loads. Form buttons are disabled until the user completes the verification.

<form action="/contact" method="POST">
    @csrf
    
    <div>
        <label for="name">Name:</label>
        <input type="text" id="name" name="name" required>
    </div>
    
    <div>
        <label for="email">Email:</label>
        <input type="email" id="email" name="email" required>
    </div>
    
    <!-- Turnstile Widget -->
    <x-turnstile :button-ids="['submit-btn']" />
    
    <button type="submit" id="submit-btn">Send Message</button>
</form>

The component automatically includes a hidden input named cf-turnstile-response containing the verification token.

Lazy Mode

In lazy mode, the widget remains hidden until the user attempts to submit the form. This provides a cleaner user experience by only showing the CAPTCHA when needed.

<form action="/newsletter" method="POST" id="newsletter-form">
    @csrf
    
    <input type="email" name="email" placeholder="Enter your email" required>
    <button type="submit" id="subscribe-btn">Subscribe</button>
</form>

<!-- Place Turnstile outside the form for lazy mode -->
<x-turnstile 
    lazy 
    form-id="newsletter-form" 
    :button-ids="['subscribe-btn']" 
/>

Customizing Appearance

<x-turnstile
    theme="dark"
    size="normal"
    container-id="custom-container"
    :button-ids="['submit-btn']"
/>

Multiple Widgets on Same Page

The component automatically handles multiple instances with unique IDs:

<!-- Login Form -->
<form action="/login" method="POST" id="login-form">
    @csrf
    <x-turnstile form-id="login-form" :button-ids="['login-btn']" />
    <button type="submit" id="login-btn">Login</button>
</form>

<!-- Register Form -->
<form action="/register" method="POST" id="register-form">
    @csrf
    <x-turnstile form-id="register-form" :button-ids="['register-btn']" />
    <button type="submit" id="register-btn">Register</button>
</form>

Custom JavaScript Callbacks

<x-turnstile 
    callback="onTurnstileSuccess" 
    :button-ids="['submit-btn']"
/>

<script>
function onTurnstileSuccess(token) {
    console.log('Verification successful:', token);
    // Custom logic here - e.g., enable additional fields
}
</script>

Livewire Integration (Standard Mode)

For Livewire components in standard mode, use the callback to bind the token to a Livewire property. Define the callback inside a @script block so it has access to $wire (a plain <script> tag cannot use @this/$wire):

<div>
    <form wire:submit.prevent="submitForm">
        <input type="email" wire:model="email" required>

        <div wire:ignore>
            <x-turnstile callback="onTurnstileSuccess" :button-ids="['submit-btn']" />
        </div>

        <button type="submit" id="submit-btn">Submit</button>
    </form>

    @script
    <script>
        window.onTurnstileSuccess = (token) => {
            $wire.set('turnstileToken', token);
        };
    </script>
    @endscript
</div>
namespace App\Livewire;

use Livewire\Component;

class ContactForm extends Component
{
    public $email;
    public $turnstileToken;

    public function submitForm()
    {
        $this->validate([
            'email' => 'required|email',
            'turnstileToken' => 'required|turnstile',
        ]);

        // Your submission logic here
    }

    public function render()
    {
        return view('livewire.contact-form');
    }
}

Wrap the component in a wire:ignore div so Livewire's DOM morphing does not destroy the rendered widget when the component re-renders.

Livewire Integration (Lazy Mode)

Lazy mode pairs naturally with Livewire: the widget stays hidden until the first tokenless submit, and the token is delivered to your callback, which then runs the action. When a callback is provided, the package never submits the form natively — the callback fully owns what happens next. This is what makes lazy mode safe for Livewire: a native submit would bypass Livewire entirely and POST to the page route (typically a 405).

The verified pattern:

<div>
    {{-- No wire:submit on the form — the package intercepts the native submit --}}
    <form id="checker-form">
        <input type="email" wire:model="email">
        <button id="checker-submit" type="submit" wire:loading.attr="disabled"
            @click="if (window.captchaToken) { $event.preventDefault(); $wire.check(); }">
            Check
        </button>
    </form>

    <div wire:ignore>
        <x-turnstile
            lazy
            form-id="checker-form"
            container-id="ts-checker"
            :button-ids="['checker-submit']"
            callback="onToolCaptchaSuccess"
        />
    </div>

    @script
    <script>
        window.onToolCaptchaSuccess = async (token) => {
            // Bind the token to the public property, then run the action
            await $wire.set('captchaToken', token);
            await $wire.check();

            // Hide the widget and reset it (tokens are single-use)
            window.captchaToken = null;
            const widget = document.getElementById('ts-checker');
            if (widget) {
                widget.style.display = 'none';
            }
            window.turnstileInstances?.['ts-checker']?.reset();
        };
    </script>
    @endscript
</div>
namespace App\Livewire;

use Livewire\Component;

class Index extends Component
{
    public string $email = '';
    public string $captchaToken = '';

    public function check(): void
    {
        $this->validate([
            'email' => 'required|email',
            'captchaToken' => 'required|turnstile',
        ]);

        // Your logic here
    }
}

Rules that make this work — don't skip any of them:

  1. No wire:submit on the form. Livewire's submit handler would fire the action on the tokenless first click, before the widget is even shown. The button stays type="submit"; tokenless clicks fall through to the package's native submit interception, which reveals and executes the widget.
  2. Alpine token guard on the button. Once window.captchaToken is set, the click is prevented from becoming a native submit and instead calls the action directly.
  3. wire:ignore wrapper. Protects the widget container and its hidden inputs from being wiped by Livewire morphs on re-render.
  4. Always reset() after each check. Turnstile tokens are single-use for siteverify; reset() clears the widget state and the hidden inputs so the next submit re-verifies instead of replaying a stale token.
  5. Give each widget an explicit container-id so your callback can target it reliably (hiding, resetting via the registry).

JavaScript Instance Registry

Every widget registers its controls under its container id, giving host apps a stable handle without knowing the generated instance id:

const controls = window.turnstileInstances['ts-checker'];

controls.reset();        // reset the widget, clear token inputs and pending flags
controls.execute();      // programmatically run the challenge (lazy mode)
controls.getWidgetId();  // underlying Turnstile widget id

Per-instance globals (window.turnstile_<instanceId>) are still exposed for backwards compatibility, but the registry is the recommended API — instance ids are generated with uniqid() and are not predictable.

Server-side Validation

Always validate the Turnstile token on the server side to prevent spam and ensure security.

Method 1: Using 'turnstile' Rule (Recommended)

The package registers a custom validation rule that you can use in your validation array:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class ContactController extends Controller
{
    public function store(Request $request)
    {
        $request->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|email',
            'cf-turnstile-response' => ['required', 'turnstile'],
        ]);

        // Process the form submission
        // ...
        
        return redirect()->back()->with('success', 'Form submitted successfully!');
    }
}

Method 2: Using the Facade

You can use the Turnstile facade to create validation rules programmatically:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Turnstile;

class ContactController extends Controller
{
    public function store(Request $request)
    {
        $request->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|email',
            'cf-turnstile-response' => ['required', 'string', Turnstile::validate()],
        ]);

        // Process the form submission
    }
}

Method 3: Using the Rule Class Directly

For more control, you can use the validation rule class directly:

<?php

use KalprajSolutions\LaravelTurnstile\Rules\ValidCloudflareTurnstile;
use Illuminate\Support\Facades\Validator;

$validator = Validator::make($request->all(), [
    'cf-turnstile-response' => ['required', new ValidCloudflareTurnstile()],
]);

if ($validator->fails()) {
    // Handle validation failure
}

Additional Facade Methods

The Turnstile facade provides additional helper methods:

// Get the site key from configuration
$siteKey = Turnstile::getSiteKey();

// Get the secret key from configuration
$secretKey = Turnstile::getSecretKey();

// Check if Turnstile is properly configured
if (Turnstile::isConfigured()) {
    // Turnstile is configured
}

API Reference

Component Parameters

Core Parameters

Parameter Type Default Description
containerId string 'cf-turnstile-container' HTML ID for the widget container element
inputName string 'cf-turnstile-response' Name attribute for the hidden input containing the token
theme string 'auto' Widget theme: 'auto', 'light', or 'dark'
size string 'flexible' Widget size: 'normal' or 'flexible'

Behavior Parameters

Parameter Type Default Description
buttonIds array [] Array of button IDs to disable during verification
buttonId string null Single button ID (alternative to buttonIds)
callback string null Name of custom JavaScript callback function
lazy boolean false Enable lazy/deferred loading mode
formId string null Form ID required for lazy mode

Livewire Parameters

Parameter Type Default Description
livewire boolean false Enable Livewire integration ( usefor future)
model string null Livewire property name for token binding (for future use)

Troubleshooting

Widget Not Appearing

  • Verify your site key is correctly set in .env and matches your domain in Cloudflare dashboard
  • Check browser console for JavaScript errors
  • Ensure stacks are included in your layout:
    @stack('custom_js_plugins')
    @stack('custom_js')

Token Validation Failing

  • Confirm your secret key is set in .env as CLOUDFLARE_SECRET_KEY
  • Check the hidden input name matches your validation (default: cf-turnstile-response)
  • Verify Cloudflare's response format in your server logs
  • Check error logs - the package logs Turnstile error codes for debugging

Lazy Mode Issues

  • Ensure formId matches the form's id attribute exactly
  • Check buttonIds contains the correct button IDs
  • Verify form submission is not prevented by other JavaScript
  • Lazy mode requires the component to be placed outside the form element

Lazy Mode + Livewire

  • Page reloads or 405s after solving the CAPTCHA — the form is being submitted natively. With a callback prop present the package never submits natively (v1.1+); make sure your form has no wire:submit competing with the interception
  • Action runs before the widget appears — remove wire:submit from the form and use the Alpine token guard pattern from the Livewire lazy-mode section
  • Widget breaks or disappears after a Livewire re-render — wrap <x-turnstile> in a wire:ignore div
  • Second submission does nothing — call window.turnstileInstances[containerId].reset() after each check; tokens are single-use and a stale token blocks re-verification
  • Token never reaches your action$wire.check(token) only works if the action accepts a parameter; otherwise bind it first with $wire.set('captchaToken', token) and validate the property with required|turnstile

Multiple Widgets on Same Page

  • The component automatically generates unique IDs, but avoid custom containerId conflicts
  • Each widget should have its own inputName if needed for separate validation
  • In lazy mode, each widget needs its own unique form-id

Network/Loading Issues

  • Turnstile loads from Cloudflare's CDN; ensure no ad blockers or firewalls are interfering
  • Check network tab for failed requests to challenges.cloudflare.com
  • Some corporate networks may block the Turnstile CDN

Common Error Codes

If you see error codes in your logs, refer to Cloudflare's Turnstile documentation:

  • missing-input-secret - The secret key is missing
  • invalid-input-secret - The secret key is invalid
  • missing-input-response - The response token is missing
  • invalid-input-response - The response token is invalid or malformed
  • bad-request - The request was rejected
  • timeout-or-duplicate - The response has expired or has been used

Security Considerations

  1. Always validate server-side - Never rely solely on client-side verification
  2. Keep your secret key secure - Never expose it in client-side code
  3. Use HTTPS - Cloudflare Turnstile requires HTTPS in production
  4. Rate limiting - Consider implementing additional rate limiting on your forms
  5. Token expiration - Tokens expire after a certain time; always validate immediately

Credits

License

The MIT License (MIT). Please see License File for more information.