happenv-com/filament-turnstile
Filament Turnstile adds a Cloudflare Turnstile CAPTCHA field to your Filament forms, letting you protect admin panels and public forms from bots and spam without puzzles or user friction. Simple to install, configure, and drop into any form schema.
Install and wire it up in five minutes
composer require happenv-com/filament-turnstile
php artisan vendor:publish --tag="filament-turnstile-config"
Create a Turnstile widget in the Cloudflare dashboard (Account → Turnstile → Add site) and copy the site key (public) and secret key (server-side only). Put them in .env:
TURNSTILE_SITE_KEY=0x4AAAAAAA...
TURNSTILE_SECRET_KEY=0x4AAAAAAA...
Then clear cached config — this is the single most common "it silently does nothing" cause:
php artisan config:clear
Where to look first
config/filament-turnstile.php — the published config is the source of truth for key names, the siteverify endpoint, and any bypass/testing flag. Read it before reading the source.src/Forms/Components/Turnstile.php — the Filament form component; its public setters tell you exactly which options (theme, size, language, mode) are supported in this version.First use case
Add the field to any Filament form and let the package's validation rule do the siteverify call:
use Happenv\FilamentTurnstile\Forms\Components\Turnstile;
public static function form(Form $form): Form
{
return $form->schema([
TextInput::make('email')->email()->required(),
Textarea::make('message')->required(),
Turnstile::make('captcha')->required(),
]);
}
The validation rule posts the token to https://challenges.cloudflare.com/turnstile/v0/siteverify with your secret and fails the field if Cloudflare returns success: false. You get server-side enforcement for free; never rely on the widget merely being present.
Protecting the login and registration pages
Filament's auth pages aren't resource forms, so you can't just add the component to a form() method. The practical routes are:
->login(YourLogin::class) on the panel.->registration(), ->passwordReset().Scoping to specific forms only
Don't add captcha globally. Wrap it so it only appears where abuse is realistic:
Turnstile::make('captcha')
->visible(fn () => ! auth()->check()) // anonymous submissions only
->required()
This keeps friction off authenticated admin workflows, which is where most day-to-day complaints about captchas originate.
Testing and local development
Cloudflare publishes always-pass test keys. Use them in phpunit.xml rather than mocking HTTP:
<env name="TURNSTILE_SITE_KEY" value="1x00000000000000000000AA"/>
<env name="TURNSTILE_SECRET_KEY" value="1x0000000000000000000000000000000AA"/>
If the package exposes a bypass/fake helper in the config (check config/filament-turnstile.php for a enabled or bypass style key in 1.2.x), prefer toggling that per-environment over conditionally removing the component — removing it changes the form schema between test and production, which hides bugs.
For Livewire feature tests that submit a Filament form, use Livewire::test(...)->fillForm([...])->call('create') and assert the record exists; with test keys the siteverify call still happens, so either keep the HTTP call live (slow, needs network) or bind a fake HTTP client with Http::fake() returning ['success' => true].
Multiple widgets on one page
The Turnstile script is loaded once and renders widgets by container. If you render two forms on a single page (e.g. a modal on top of a page form), verify both widgets render — most integration bugs here come from the script only auto-rendering on first paint. If needed, use explicit rendering with a render callback and a distinct container id per form.
Spam-response workflow
Log the siteverify payload (at minimum error-codes and hostname) whenever validation fails. Cloudflare's failure reasons are genuinely actionable: invalid-input-response (expired/reused token) means a UX problem on your side, invalid-input-secret means a deployment/config problem, hostname-mismatch means you reused keys across environments.
wire:model update, wrap the field container in wire:ignore (via the component's extraAttributes) or check whether the package already does this in its Blade view.config:cache in production. Keys read via env() inside a cached config are frozen. Set the env vars, then re-run php artisan config:cache as part of deploy — a missing secret key otherwise fails open-looking but closed (every submission rejected) or, worse, if the package treats an empty secret as "disabled", fails open.https://challenges.cloudflare.com for script-src and frame-src. Brave's shields and some privacy extensions block Turnstile outright; make sure the form fails with a readable message rather than a silent required error on an invisible field.staging.example.com with production keys will fail with hostname-mismatch. Register staging hostnames or use test keys outside production.v1.0.0...v1.2.0) with no prose. Before upgrading, open that diff and skim src/ and config/ for renamed config keys or changed component signatures — you're skipping a minor version boundary, so treat it as a real review rather than a patch bump. Pin ^1.2 and run your form tests after the bump.->theme(), ->size(), and ->language() setters map directly to Turnstile's render options — useful for matching a dark-mode admin panel. If you need to customise markup, the Blade view is publishable/overridable; keep overrides minimal so upgrades stay cheap.How can I help you explore Laravel packages today?