happenv-com / filament-turnstile
Cloudflare Turnstile panel plugin for Filament v4 and v5 — register once, auto-protect login (and optionally registration / password reset).
Requires
- php: ^8.2
- filament/filament: ^4.0|^5.0
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- orchestra/testbench: ^11.0
- pestphp/pest: ^5.0
- pestphp/pest-plugin-browser: ^5.0
- pestphp/pest-plugin-laravel: ^5.0
- pestphp/pest-plugin-livewire: ^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Cloudflare Turnstile panel plugin for Filament v4 and v5. Register it once on a panel and login is protected automatically — optionally registration and password-reset request too. No form schema edits for the common case.
Based on muazzambuilds/filament-turnstile by Muazzam Builds.
use Happenv\FilamentTurnstile\TurnstilePlugin; $panel->plugin(TurnstilePlugin::make());
Key features
- Login, registration and password reset — one panel plugin protects Filament's auth pages; each page is switched on or off on its own. See Panel plugin.
- Multi-factor authentication aware — Turnstile runs on the password step only, never again on the app code or passkey challenge.
- Custom auth pages — one trait per page (
Login,Register,RequestPasswordReset) adds Turnstile to your own subclasses, plus aTurnstileform field for any other Filament form. See Custom auth pages. - Testing helpers — Livewire test mixins (
assertTurnstileBlocks(),assertTurnstileAllows(),passTurnstile(), …) for Pest and PHPUnit, with Cloudflare's test keys answered locally, so tests never reach Cloudflare. See Testing your application. - Laravel 11, 12 and 13, Filament 4 and 5 — see Requirements.
- Translations — validation messages in 64 languages, every locale Filament ships. See Supported languages.
- Safe without keys — with no keys configured the widget is hidden and verification is skipped, so local apps still boot.
Requirements
| Package | Versions |
|---|---|
| PHP | 8.2 – 8.5 |
| Laravel | 11, 12, 13 |
| Filament | 4, 5 |
CI runs the test suite on PHP 8.3 – 8.5 with Laravel 12 and 13 and Filament 4 and 5. PHP 8.2 and Laravel 11 are allowed by composer.json but cannot be tested in CI (the test tooling needs PHP 8.3, and Composer refuses every Laravel 11 release because of security advisories).
Installation
Install the package via Composer:
composer require happenv-com/filament-turnstile
Add your keys to .env:
TURNSTILE_SITE_KEY=your-site-key TURNSTILE_SECRET_KEY=your-secret-key
Create a widget and get keys at dash.cloudflare.com → Turnstile.
Register the plugin in your panel provider — see Panel plugin below.
The widget's wrapper uses a few Tailwind classes. If your panel uses a custom theme, add the package's views to its CSS file so Tailwind generates them:
@source '../../../../vendor/happenv-com/filament-turnstile/resources/**/*.blade.php';
Configuration
Publish the config file (optional):
php artisan vendor:publish --tag=filament-turnstile-config
// config/filament-turnstile.php return [ 'site_key' => env('TURNSTILE_SITE_KEY'), 'secret_key' => env('TURNSTILE_SECRET_KEY'), 'verify_url' => env('TURNSTILE_VERIFY_URL', 'https://challenges.cloudflare.com/turnstile/v0/siteverify'), 'connect_timeout' => (int) env('TURNSTILE_CONNECT_TIMEOUT', 5), 'timeout' => (int) env('TURNSTILE_TIMEOUT', 10), 'theme' => env('TURNSTILE_THEME', 'auto'), 'size' => env('TURNSTILE_SIZE', 'flexible'), 'language' => env('TURNSTILE_LANGUAGE'), 'reset_event' => env('TURNSTILE_RESET_EVENT', 'turnstile-reset'), ];
Optionally, publish the views:
php artisan vendor:publish --tag=filament-turnstile-views
Usage
Panel plugin (recommended)
Call ->plugin(...) after ->login() / ->registration() / ->passwordReset() so the plugin can swap the auth page classes:
use Happenv\FilamentTurnstile\TurnstilePlugin; public function panel(Panel $panel): Panel { return $panel ->login() ->registration() ->passwordReset() ->plugin( TurnstilePlugin::make() ->protectRegistration() // opt-in ->protectPasswordReset() // opt-in (request page only) ); }
Defaults: login is protected; registration and password-reset request are not.
Each page is toggled independently, so any combination works — e.g. registration and password reset only:
TurnstilePlugin::make() ->protectLogin(false) ->protectRegistration() ->protectPasswordReset()
Login protection works with Filament multi-factor authentication (app codes, passkeys): Turnstile appears only on the password step, and the multi-factor challenge step has no widget and no second verification.
Widget options
TurnstilePlugin::make() ->theme('auto') // auto | light | dark ->size('flexible') // normal | flexible | compact ->language('en-US') ->enabled(true);
Manual form field
For custom auth pages or any Filament form:
use Happenv\FilamentTurnstile\Forms\Components\Turnstile; Turnstile::make('cf-turnstile-response') ->theme('dark') ->size('compact') ->turnstileAction('contact');
Disable auto-swap and extend or compose yourself:
->login(CustomLogin::class) ->plugin(TurnstilePlugin::make()->protectLogin(false))
Custom auth pages
Keep extending Filament's page and add the matching trait from Happenv\FilamentTurnstile\Concerns — it carries everything the packaged page does:
| Your page extends | Use trait |
|---|---|
Login |
InteractsWithTurnstileLogin |
Register |
InteractsWithTurnstileRegistration |
RequestPasswordReset |
InteractsWithTurnstilePasswordReset |
What each trait adds:
- Every trait gives the page a default form with the widget and resets the widget on any validation error.
InteractsWithTurnstileLoginalso resets the widget after a wrong password and skips it on the multi-factor challenge step (app codes, passkeys). The token was already verified on the password step, and Cloudflare accepts a token only once.InteractsWithTurnstileRegistrationalso keeps the token out of the data passed to user creation.InteractsWithTurnstilePasswordResetalso resets the widget after each request, so another one can be sent.
InteractsWithTurnstile is the shared base — use it directly only for forms other than these auth pages.
use Filament\Auth\Pages\Login as BaseLogin; use Filament\Schemas\Schema; use Happenv\FilamentTurnstile\Concerns\InteractsWithTurnstileLogin; class Login extends BaseLogin { use InteractsWithTurnstileLogin; // Optional — the trait provides a default form. When you override it, // keep the Turnstile component in the schema. public function form(Schema $schema): Schema { return $schema ->components([ $this->getEmailFormComponent(), $this->getPasswordFormComponent(), $this->getTurnstileFormComponent(), ]); } }
Register the page yourself and turn off the plugin's swap for it, otherwise the plugin replaces it with the packaged page:
->login(Login::class) ->plugin(TurnstilePlugin::make()->protectLogin(false))
Use the login trait (not the generic InteractsWithTurnstile) on login pages: it keeps Turnstile off the multi-factor challenge step.
Testing your application
Local and CI keys
Cloudflare test keys (any hostname, including localhost):
| Purpose | Site key | Secret key |
|---|---|---|
| Always passes | 1x00000000000000000000AA |
1x0000000000000000000000000000000AA |
| Always blocks | 2x00000000000000000000AB |
2x0000000000000000000000000000000AA |
| Token already spent | — | 3x0000000000000000000000000000000AA |
With a test site key the widget produces the dummy token XXXX.DUMMY.TOKEN.XXXX. The package answers the test secret keys itself, the same way Cloudflare does — verification never leaves the app, so it works offline and with Http::preventStrayRequests(). Production secret keys reject the dummy token.
If keys are missing, the widget is hidden and server validation is skipped so local apps without Turnstile still boot.
Livewire test helpers
The package adds Turnstile helpers to Livewire's test object (Livewire::test(...), or livewire(...) from pest-plugin-livewire), the same way Filament adds fillForm() — they work in Pest and PHPUnit alike. Each helper switches the app to the Cloudflare test keys above and fills the dummy token, so no request reaches Cloudflare.
Test the page your panel registers — the packaged one, or your own page using a trait:
use Happenv\FilamentTurnstile\Pages\Auth\Login; use Livewire\Livewire; // Asserts: Turnstile rejects the token and stops the action. Livewire::test(Login::class) ->fillForm(['email' => 'jane@example.com', 'password' => 'password']) ->assertTurnstileBlocks('authenticate'); // Asserts: Turnstile accepts the token and does not stop the action // (other validation errors are not its concern). Livewire::test(Login::class) ->fillForm(['email' => 'jane@example.com', 'password' => 'password']) ->assertTurnstileAllows('authenticate');
| Helper | What it does |
|---|---|
assertTurnstileBlocks($method) |
Fails the token, calls $method, asserts a Turnstile error. |
assertTurnstileAllows($method) |
Passes the token, calls $method, asserts no Turnstile error. |
assertTurnstileActive() |
The form shows the widget and verifies its token on submit. |
assertTurnstileInactive() |
The form has no widget, or it is hidden — keys missing, plugin disabled, page not protected, or the multi-factor challenge step. |
passTurnstile() |
Only sets up a passing token — follow with your own call() and assertions. |
failTurnstile() |
Only sets up a failing token. |
assertTurnstileBlocks() and assertTurnstileAllows() first assert the widget is active, so they fail on a page Turnstile does not protect instead of passing silently. Every helper takes the schema name as its last argument (default form); the two call helpers take the method's arguments before it:
->assertTurnstileAllows('submit', [$ticketId], schema: 'contactForm')
Use passTurnstile() to get past Turnstile in tests about something else — the full login with a multi-factor challenge, for example:
Livewire::test(Login::class) ->fillForm(['email' => 'jane@example.com', 'password' => 'password']) ->passTurnstile() ->call('authenticate') ->assertTurnstileInactive() // the challenge step has no widget ->fillForm(['app' => ['code' => $code]], 'multiFactorChallengeForm') // keyed by the provider id ->call('authenticate');
The helpers change the filament-turnstile.* keys in the config for the rest of that test.
Browser tests
The helpers above extend Livewire's test object; Pest's browser pages cannot be extended the same way. In a browser test, switch to the test keys yourself — Pest serves the app from the test process, so the config applies to the page. The real widget then solves itself; wait for its token before submitting:
use Happenv\FilamentTurnstile\Testing\TestsTurnstile; it('logs in through Turnstile', function () { config([ 'filament-turnstile.site_key' => TestsTurnstile::PASSING_SITE_KEY, 'filament-turnstile.secret_key' => TestsTurnstile::PASSING_SECRET_KEY, // BLOCKING_SECRET_KEY to reject the token ]); visit('/admin/login') ->assertScript( "document.querySelector('.fi-fo-turnstile [name=\"cf-turnstile-response\"]')?.value", TestsTurnstile::DUMMY_TOKEN, ) ->fill('[id="form.email"]', 'jane@example.com') ->fill('[id="form.password"]', 'password') ->submit() ->assertPathIs('/admin'); });
The widget loads from challenges.cloudflare.com, so browser tests need network access.
Translations
Validation messages ship in every locale Filament ships — see Supported languages. The app locale picks the language.
To change a message or add a language, publish the files:
php artisan vendor:publish --tag=filament-turnstile-translations
The widget itself follows the browser language; set ->language() or TURNSTILE_LANGUAGE to fix it.
Supported languages
| Language | Language | Language | Language |
|---|---|---|---|
Amharic am |
Persian fa |
Lithuanian lt |
Slovenian sl |
Arabic ar |
Finnish fi |
Mizo lus |
Albanian sq |
Azerbaijani az |
Filipino fil |
Latvian lv |
Serbian (Cyrillic) sr_Cyrl |
Bulgarian bg |
French fr |
Macedonian mk |
Serbian (Latin) sr_Latn |
Bengali bn |
Hebrew he |
Mongolian mn |
Swedish sv |
Bosnian bs |
Hindi hi |
Malay ms |
Swahili sw |
Catalan ca |
Croatian hr |
Burmese my |
Tajik tg |
Central Kurdish ckb |
Hungarian hu |
Norwegian Bokmål nb |
Thai th |
Czech cs |
Armenian hy |
Nepali ne |
Turkish tr |
Danish da |
Indonesian id |
Dutch nl |
Ukrainian uk |
German de |
Italian it |
Polish pl |
Urdu ur |
Greek el |
Japanese ja |
Portuguese pt |
Uzbek uz |
English en |
Georgian ka |
Portuguese (Brazil) pt_BR |
Vietnamese vi |
Spanish es |
Khmer km |
Romanian ro |
Chinese (Simplified) zh_CN |
Estonian et |
Korean ko |
Russian ru |
Chinese (Hong Kong) zh_HK |
Basque eu |
Kurdish ku |
Slovak sk |
Chinese (Traditional) zh_TW |
Development
composer test # unit and feature tests composer test-browser # browser tests (once: npm ci && npx playwright install chromium) composer phpstan # static analysis composer cs # fix code style: composer normalize, Rector, Pint composer ci # everything CI checks, locally
The browser tests drive the real widget from challenges.cloudflare.com with Cloudflare's test keys, so they need network access.
Upgrading
Breaking changes and how to migrate are described in UPGRADING for every major version.
Changelog
See CHANGELOG and GitHub releases for what has changed recently.
Contributing
See CONTRIBUTING for details.
Security vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
- Happenv sp. z o.o.
- webard
- Muazzam Builds — author of the original muazzambuilds/filament-turnstile
- All contributors
License
The MIT License (MIT). See License File for more information.
