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.
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.
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.
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)
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.
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).
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.
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.
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.onValidationError on day one, not after the first support ticket.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.staging.example.com — add the hostname (and localhost) to the Turnstile widget's allowed domains.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.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.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.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.How can I help you explore Laravel packages today?