coderflex/filament-turnstile
Filament Turnstile adds Cloudflare Turnstile bot protection to Filament forms, with a ready-made field for login and other forms. Works with Filament V2-V5 and ships test keys so you can get started quickly.
1. Match the package major to your Filament major — before anything else. The version matrix is the single source of breakage:
| Filament | Package | Notes |
|---|---|---|
| v5 | ^4.0 |
Laravel 13, PHP 8.4 |
| v4 | ^3.0 |
Schema-based form API, laravel-turnstile ^2.0 |
| v3 | ^2.0 |
Forms\Form API |
| v2 | ^1.0 |
legacy |
composer require coderflex/filament-turnstile "^4.0"
2. Pull keys from Cloudflare and put them in .env. The package reads them through the underlying coderflex/laravel-turnstile config, so use the exact env names:
TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
Those are Cloudflare's dummy always-pass test keys — start with them so you know a failure is your code and not your keys. The matching always-fail pair is 2x00000000000000000000AB / 2x0000000000000000000000000000000AA.
3. Where to look first. The README here is thin by design; the real behaviour lives one layer down. Read, in order:
vendor/coderflex/laravel-turnstile/README.md — server-side verification, config keys, validation rule.vendor/coderflex/filament-turnstile/src/Forms/Components/Turnstile.php — what the field actually registers (rule, dehydration, asset loading).vendor/coderflex/filament-turnstile/resources/ — the Alpine/JS that listens for reset-captcha; this tells you whether the event is global or scoped per instance.4. First use case: harden the panel login. This is 90% of real deployments. Subclass the page, add the field, override the panel's login class:
// app/Filament/Pages/Auth/Login.php
namespace App\Filament\Pages\Auth;
use Coderflex\FilamentTurnstile\Forms\Components\Turnstile;
use Filament\Pages\Auth\Login as AuthLogin;
use Filament\Schemas\Schema; // Filament v4/v5; use Filament\Forms\Form on v3
class Login extends AuthLogin
{
public function form(Schema $form): Schema
{
return $form->schema([
$this->getEmailFormComponent(),
$this->getPasswordFormComponent(),
$this->getRememberFormComponent(),
Turnstile::make('captcha')->label('Captcha')->theme('auto'),
]);
}
}
// AdminPanelProvider
->login(\App\Filament\Pages\Auth\Login::class)
Then submit the form once with a wrong secret key and confirm it fails. If it succeeds, your server-side verification is not wired up and you have a decorative widget — see Gotchas.
Reset-on-failure is not optional. Turnstile tokens are single-use and expire (~5 min). Filament keeps form state across a failed submission, so the second attempt posts a spent token and gets timeout-or-duplicate — the user is permanently stuck. Wire the reset in both places:
// Generic Livewire form component
protected function onValidationError(ValidationException $exception): void
{
$this->dispatch('reset-captcha');
}
// Login page specifically
protected function throwFailureValidationException(): never
{
$this->dispatch('reset-captcha');
parent::throwFailureValidationException();
}
Reset after success too, in flows where the same component stays mounted (repeat submissions, "add another" modals): dispatch reset-captcha after a successful save, alongside $this->form->fill().
Keep the token out of your model writes. The field stores the response token in form state. On any Resource create/edit form, add:
Turnstile::make('captcha')->dehydrated(false)
so $this->form->getState() / Model::create($data) never sees a captcha key. On auth pages the state is discarded anyway, so it's only mandatory on persisted forms.
Scope it by intent, not by fear. Don't captcha everything — it costs conversions and adds a third-party round trip:
Turnstile::make('captcha')
->visible(fn () => auth()->guest())
->visible(fn () => RateLimiter::tooManyAttempts('contact:'.request()->ip(), 3))
Registration and password-reset pages follow the identical pattern: subclass Filament\Pages\Auth\Register / RequestPasswordReset, override form(), and register via ->registration() / ->passwordReset() in the panel provider. Do all three or none — a captcha on login only is theatre.
Front-end (non-panel) forms. The component is a Filament field, so it renders inside any Livewire component using a Filament schema — but asset registration comes from the panel. If you use it outside a panel, expect to include https://challenges.cloudflare.com/turnstile/v0/api.js yourself and verify the widget mounts. In practice, keep Turnstile on panel/auth pages and use plain Cloudflare markup or laravel-turnstile directly on public marketing forms.
Testing. Never let CI talk to Cloudflare. Either flip turnstile.enabled to false in phpunit.xml, or fake the verify endpoint so you can also test the rejection path:
Http::fake([
'challenges.cloudflare.com/*' => Http::response(['success' => true]),
]);
Then add one test with ['success' => false, 'error-codes' => ['invalid-input-response']] asserting the form does not submit. That test is your only real proof the rule is applied.
->rules([...]), and don't assume it's active because the widget drew. Prove it with the dummy always-fail keys or a Http::fake returning success: false.^2.4.1/^3.x/^4.x and sanity-check that the token arrives in state before submit. If it can't render, it can't be solved.reset-captcha is a browser event by name. If two Turnstile fields are on one page, one dispatch may reset both (or, historically, the wrong one). Prefer one captcha per page; if you truly need two, inspect the vendor JS for instance scoping first.theme('auto') follows the OS, not Filament. Cloudflare reads prefers-color-scheme; Filament's dark-mode toggle writes a .dark class. A user who flips Filament to dark on a light OS will see a light widget. Either accept it, or listen for Filament's theme toggle and re-init.en-US, not en. There was a fix for "wrong default language format" in v2.3.0 — don't pass app()->getLocale() straight through; map it to Cloudflare's supported list.localhost works out of the box; staging/preview domains must be added to the widget or they fail with invalid-domain. First deploy to staging is a classic "it worked locally" moment.challenges.cloudflare.com. Strict CSP needs it in script-src, frame-src, and connect-src.cf-turnstile-response entry? (2) does form state contain a token on submit? (3) does your server log an outbound siteverify request? Step 3 is invisible in the browser network tab — it's server-side, so watch storage/logs or Http::fake it.Turnstile::make() call isolated in as few places as possible (a fields() helper or trait), and assume that a future Filament upgrade may strand you until a release lands. The escape hatch is cheap: swap the field for raw Cloudflare markup plus coderflex/laravel-turnstile's validation rule — same backend, one layer less.How can I help you explore Laravel packages today?