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

Filament Turnstile Laravel Package

l3aro/filament-turnstile

Filament Turnstile is a lightweight plugin that integrates Cloudflare Turnstile captcha into Filament apps. Install via Composer, publish the config, add your site and secret keys to .env, and optionally customize the reset event name.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and wire up in three commands:

composer require l3aro/filament-turnstile
php artisan vendor:publish --tag="filament-turnstile-config"
# optional, only if you need to restyle the widget
php artisan vendor:publish --tag="filament-turnstile-views"

Add credentials to .env — grab a Site Key and Secret Key from the Cloudflare dashboard, or use Cloudflare's always-pass dummy pair while developing:

TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA

Where to look first: config/filament-turnstile.php (three keys only — key, secret, reset_event) and l3aro\FilamentTurnstile\Forms\Turnstile. The package is deliberately thin: one form field, one facade, one config file. There is no service to boot, no panel plugin to register.

First use case — protect the login form. Drop the field into a form schema:

use l3aro\FilamentTurnstile\Forms\Turnstile;

Turnstile::make('captcha'),

The highest-value target on day one is App\Filament\Pages\Auth\Login, because that is the endpoint bots actually hit. Everything else (registration, contact forms, password reset) follows the same pattern afterwards.

Implementation Patterns

The field with options

Turnstile is a Filament form component, so it chains like any other field. The theme/size are backed enums, and language() takes a Cloudflare locale code:

use l3aro\FilamentTurnstile\Enums\TurnstileSize;
use l3aro\FilamentTurnstile\Enums\TurnstileTheme;
use l3aro\FilamentTurnstile\Forms\Turnstile;

Turnstile::make('captcha')
    ->theme(TurnstileTheme::Auto)      // Auto | Light | Dark
    ->size(TurnstileSize::Normal)      // Normal | Compact | Flexible
    ->language('en-US')
    ->resetEvent('reset-captcha');

Passing strings instead of enums also works (v1.0.7 fixed BackedEnum → string conversion for the Blade view), but use the enums — they give you IDE completion on the Cloudflare config surface.

Overriding the Filament login page

The canonical integration: subclass Filament\Pages\Auth\Login, rebuild getForms(), keep all the inherited components so you don't lose email/password/remember behaviour, then point the panel at your class.

namespace App\Filament\Pages\Auth;

use Filament\Forms\Form;
use Filament\Pages\Auth\Login as AuthLogin;
use l3aro\FilamentTurnstile\Forms\Turnstile;

class Login extends AuthLogin
{
    protected function getForms(): array
    {
        return [
            'form' => $this->form(
                $this->makeForm()
                    ->schema([
                        $this->getEmailFormComponent(),
                        $this->getPasswordFormComponent(),
                        $this->getRememberFormComponent(),
                        Turnstile::make('captcha'),
                    ])
                    ->statePath('data'),
            ),
        ];
    }
}
// AdminPanelProvider::panel()
->login(\App\Filament\Pages\Auth\Login::class)

Resetting the widget (the pattern you'll actually write most)

A Turnstile token is single-use. If the form fails validation and the user retries with a stale token, Cloudflare rejects it and the form appears permanently broken. Filament calls onValidationError() on validation failure — hook it:

use Illuminate\Validation\ValidationException;
use l3aro\FilamentTurnstile\Facades\FilamentTurnstileFacade;

protected function onValidationError(ValidationException $exception): void
{
    $this->dispatch(FilamentTurnstileFacade::getResetEventName());
}

For failed authentication specifically, throw through Filament's helper so onValidationError fires:

if (! Auth::attempt($this->data)) {
    $this->throwFailureValidationException([
        'email' => 'Invalid email or password.',
    ]);
}

If you overrode throwFailureValidationException() in your Login subclass, dispatch there instead — that is the version the README's real-life example uses and it covers both the validation path and the auth path in one place.

Reuse across public forms

The field is generic, so any Filament form benefits: registration, contact, "request a demo", feedback widgets rendered via a front-end panel. Keep a small base trait or shared schema method so every public form gets the same resetEvent name and options, then customise per form only where a compact widget makes more sense (inline footer forms).

Programmatic reset from anywhere

The facade's getter is the supported way to avoid hardcoding the event string:

$this->dispatch(FilamentTurnstileFacade::getResetEventName());

Call it after a successful submission that doesn't redirect, so the next visitor on the same Livewire component gets a fresh challenge rather than a consumed one.

Testing

composer test runs the suite (Pest). For your own app tests, Cloudflare's dummy keys always pass, which lets you assert the happy path without stubbing HTTP. Note v1.1.1's behaviour change: the field is only required when credentials are configured — so in CI with no keys set, forms validate without a token. That is convenient, but it means your tests won't exercise the failure path unless you explicitly set dummy keys in phpunit.xml.

Gotchas and Tips

  • The reset event is a global string. reset_event is a single config value, and ->resetEvent() is per-field. If you render two captchas on one page (e.g. login and register in adjacent modals) and they share a name, resetting one resets both — usually harmless, occasionally confusing. Give them distinct names if the UI demands independence.
  • Resetting is not optional after failure. The single most common bug report with any Turnstile integration is "captcha is invalid" on the second submit. Wire onValidationError on day one, not after the first support ticket.
  • Credentials gate requirement (v1.1.1). If TURNSTILE_SITE_KEY/SECRET_KEY are empty, the field becomes non-required. This is great for local dev and bad as a silent production misconfiguration — add a deploy check that the keys are non-empty in production.
  • Dummy keys always pass. Never let Cloudflare's test keys reach a production environment; they provide zero protection and look identical to a working setup in the browser.
  • Domain allowlist lives in Cloudflare, not in your app. Nothing in this package will tell you why the widget refuses to render on staging.example.com — add the hostname (and localhost) to the Turnstile widget's allowed domains.
  • Token lifetime is short. Tokens expire after ~300 seconds. Long-lived forms (multi-step wizards, slow typing) will fail validation; reset the widget when advancing steps or refreshing state.
  • language() vs. app locale. The field takes a Cloudflare locale code such as en-US, not a Laravel locale like en. Map app()->getLocale() yourself if you want the widget to follow the app.
  • Debugging a widget that never appears: check for a CSP blocking challenges.cloudflare.com, an ad-blocker, or the script being torn down by Livewire morphs on wire:navigate. Confirm window.turnstile exists in the console; if it does but the field is empty, the container was replaced after mount.
  • The secret key must never reach the browser. It is read server-side from config only — don't inline it into a Blade view while debugging.
  • Views are publishable, so customisation is cheap. Published views live in resources/views/vendor/filament-turnstile; use TURNSTILE_RESET_EVENT in .env rather than editing the config file when you only need a different event name across environments.
  • Version alignment. v1.0.6 targets Filament v5, v1.1.0 adds Laravel 13. Given 11 stars and no dependents, pin the version in composer.json and run the suite after any Filament upgrade — this package's releases are mostly dependency bumps, so the field itself is stable but the surrounding form API is not.
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.
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core