vuillaume-agency / symfony-turnstile
Symfony bundle for Cloudflare Turnstile - a privacy-friendly, GDPR-compliant CAPTCHA alternative. Drop-in reCAPTCHA replacement with zero user friction.
Package info
github.com/vuillaume-agency/symfony-turnstile
pkg:composer/vuillaume-agency/symfony-turnstile
Requires
- php: >=8.2
- symfony/form: ^7.4|^8.0
- symfony/framework-bundle: ^7.4|^8.0
- symfony/http-client: ^7.4|^8.0
- symfony/twig-bundle: ^7.4|^8.0
- symfony/validator: ^7.4|^8.0
- symfony/yaml: ^7.4|^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.65
- phpstan/phpstan: ^2.1
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^11.5
- rector/rector: ^2.0
- symfony/browser-kit: ^7.4|^8.0
- symfony/phpunit-bridge: ^8.0
- symfony/security-bundle: ^7.4|^8.0
- symfony/security-http: ^7.4|^8.0
Suggests
- symfony/security-http: Needed by the protect_password_login option: Turnstile on form_login and custom login-form authenticators (symfony/security-bundle installs it)
Provides
None
Conflicts
None
Replaces
None
README
A Symfony bundle to integrate Cloudflare Turnstile on your forms. Turnstile is a privacy-preserving alternative to CAPTCHA that doesn't require user interaction.
Features
- Zero user friction — No puzzles, no clicking on traffic lights
- Login page covered — One option protects
form_loginand custom login-form authenticators, something a form field cannot do (see Protecting the login form). As of October 2026 we found no other Symfony Turnstile bundle doing it - GDPR-friendly — No cookie consent required, privacy-first design
- 22 languages included — All major European languages supported
- Symfony 8 ready — Full support for Symfony 7.4 LTS and 8.x
- Customizable messages — Override error messages via form options or translations
- Easy theming — Light, dark, or auto theme support
- Fully tested — PHPUnit tests and PHPStan static analysis
Quick Start
composer require vuillaume-agency/symfony-turnstile
# .env TURNSTILE_KEY="your-site-key" TURNSTILE_SECRET="your-secret-key"
// In your form ->add('captcha', TurnstileType::class)
Get your free keys at Cloudflare Dashboard.
Why Turnstile over reCAPTCHA?
| Feature | Turnstile | reCAPTCHA |
|---|---|---|
| Usually invisible (no puzzles) | Yes | Yes (v3 only) |
| GDPR compliant by design | Yes | No (requires consent) |
| Uses cookies | No | Yes |
| Data used for advertising | No | Unclear |
| Script size | ~30KB | ~80KB |
| Free unlimited | Yes | Yes (with limits) |
GDPR considerations
For European websites, reCAPTCHA poses significant compliance challenges:
- Cookies: reCAPTCHA sets cookies that require user consent under GDPR
- Data transfer: User data is sent to Google servers in the US
- Consent required: Must obtain explicit consent before loading reCAPTCHA
- Fines: French DPA (CNIL) has fined companies for improper reCAPTCHA implementation
Turnstile is designed with privacy in mind:
- No cookies: Doesn't set any cookies, no consent banner needed
- Minimal data: Only collects what's necessary for bot detection (IP, TLS fingerprint)
- No advertising: Cloudflare explicitly prohibits using collected data for ads or tracking
- GDPR-ready: Can be used without additional consent mechanisms
About this fork
Originally a fork of pixelopen/cloudflare-turnstile-bundle by Pixel Développement.
What's new:
- Password login protection (
protect_password_login): the upstream bundle, like every Symfony Turnstile bundle we know of, stops at the form field; the login page needs a hook in the security system, which 1.2 provides - Symfony 7.4 LTS and 8.x (both bundles support Symfony 8 now; this one targets 7.4 LTS and up)
- 22 languages for error messages
- Customizable error messages via form options
disable_submit_until_verifiedoption to prevent premature form submission- PHP 8.2+ with modern features (readonly, constructor promotion)
- Active maintenance and community contributions welcome
Requirements
| Requirement | Version |
|---|---|
| PHP | >= 8.2 |
| Symfony | >= 7.4 |
Installation
Step 1: Install the package
composer require vuillaume-agency/symfony-turnstile
Step 2: Register the bundle
Add the bundle to your config/bundles.php:
return [ // ... VuillaumeAgency\TurnstileBundle\VuillaumeAgencyTurnstileBundle::class => ['all' => true], ];
Step 3: Add your Cloudflare credentials
Get your keys from the Cloudflare Dashboard and add them to your .env file:
TURNSTILE_KEY="your-site-key" TURNSTILE_SECRET="your-secret-key"
That's it! The bundle automatically reads from these environment variables.
Optional: Custom configuration
If you need to customize the bundle, create config/packages/vuillaume_agency_turnstile.yaml:
vuillaume_agency_turnstile: key: '%env(TURNSTILE_KEY)%' # Default: reads from TURNSTILE_KEY env var secret: '%env(TURNSTILE_SECRET)%' # Default: reads from TURNSTILE_SECRET env var enable: true # Default: true disable_submit_until_verified: false # Default: false
| Option | Type | Default | Description |
|---|---|---|---|
key |
string | %env(TURNSTILE_KEY)% |
Your Turnstile site key (public) |
secret |
string | %env(TURNSTILE_SECRET)% |
Your Turnstile secret key (private) |
enable |
boolean | true |
Enable/disable validation |
disable_submit_until_verified |
boolean | false |
Disable submit buttons until Turnstile verification completes |
Usage
Adding Turnstile to a form
Use TurnstileType in your form builder:
<?php namespace App\Form; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\FormBuilderInterface; use VuillaumeAgency\TurnstileBundle\Type\TurnstileType; class ContactType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { $builder ->add('email', EmailType::class) ->add('message', TextareaType::class) ->add('captcha', TurnstileType::class, [ 'label' => false, ]) ->add('submit', SubmitType::class); } }
Widget customization
Customize the Turnstile widget appearance:
->add('captcha', TurnstileType::class, [ 'label' => false, 'attr' => [ 'data-theme' => 'dark', // 'light', 'dark', or 'auto' 'data-size' => 'compact', // 'normal' or 'compact' 'data-action' => 'contact', // Custom action name for analytics ], ])
See Cloudflare Turnstile documentation for all available options.
Disable submit until verified
To prevent form submission before Turnstile completes, enable automatic submit button disabling:
# config/packages/vuillaume_agency_turnstile.yaml vuillaume_agency_turnstile: disable_submit_until_verified: true
When enabled:
- Submit buttons are disabled on page load (server-side)
- JavaScript enables them after Turnstile verification succeeds
- Buttons are re-disabled if verification expires or fails
- Nothing is disabled when the bundle itself is off (
enable: false), since the widget is not rendered
The widget declares its data-callback handlers as global functions named after the widget id. They are
defined by the inline script rendered with the widget, before Turnstile renders it, so no code of yours is
needed and no other script may redefine them.
This improves UX by preventing users from clicking submit before the challenge is ready.
Important: This feature requires using
SubmitTypein your form builder. Raw HTML<button>elements in Twig templates will not be affected.
use Symfony\Component\Form\Extension\Core\Type\SubmitType; $builder ->add('captcha', TurnstileType::class) ->add('submit', SubmitType::class, ['label' => 'Send']);
Custom error messages
Pass custom messages directly as form options:
->add('captcha', TurnstileType::class, [ 'label' => false, 'missing_response_message' => 'Please verify you are human.', 'verification_failed_message' => 'Verification failed, please try again.', ])
Or override translations in translations/validators.{locale}.yaml:
turnstile.missing_response: Please verify you are human. turnstile.verification_failed: Verification failed. Please try again.
Protecting the login form
Symfony's form_login reads the login POST itself, outside any form type, so a Turnstile field
in a form class does not apply to the login page. Since 1.2 one option covers it:
# config/packages/vuillaume_agency_turnstile.yaml vuillaume_agency_turnstile: protect_password_login: true
Then render the widget in your login template, above the submit button:
{{ turnstile_widget({'data-action': 'login'}) }}
The token is verified while the passport is checked, after the CSRF token and before the account
is looked up, so a refused challenge reveals nothing about the account. A refusal counts as a
failed attempt for login_throttling. The check is skipped when enable is false, as for
form fields.
What it covers: form_login and every authenticator extending AbstractLoginFormAuthenticator
(the one make:security:form-login and the documentation use). What it leaves alone:
http_basic, json_login, login_link, remember_me, OAuth and Security::login(), so API
clients keep working. A login written outside the Security component (a controller checking the
password itself) does not go through the passport: use the TurnstileType field on that form
instead.
One token, one verification. If your project already verifies cf-turnstile-response in a
listener or an authenticator, remove it when you turn the option on: a Turnstile token is
accepted once.
The message shown on refusal is password_login_message (default: "The security check failed.
Please try again."), translated through the security domain
(error.messageKey|trans(error.messageData, 'security') in the template that
make:security:form-login generates).
The option acts through symfony/security-http, which symfony/security-bundle installs. In a
project without it there is no password login to protect, and the option has no effect. It is off
by default in 1.x and will be on by default in 2.0.
Translations
The bundle provides error messages in 22 European languages:
| Language | Code | Language | Code | |
|---|---|---|---|---|
| English | en |
Hungarian | hu |
|
| French | fr |
Swedish | sv |
|
| Spanish | es |
Danish | da |
|
| German | de |
Finnish | fi |
|
| Italian | it |
Norwegian | nb |
|
| Portuguese | pt |
Greek | el |
|
| Dutch | nl |
Czech | cs |
|
| Polish | pl |
Slovak | sk |
|
| Romanian | ro |
Slovenian | sl |
|
| Bulgarian | bg |
Lithuanian | lt |
|
| Croatian | hr |
Latvian | lv |
|
| Ukrainian | uk |
Estonian | et |
|
| Turkish | tr |
Migrating from pixelopen/cloudflare-turnstile-bundle
This bundle is a modernized fork. Here's how to migrate:
Why migrate: Symfony 7.4/8 support, 22 languages, customizable messages, and since 1.2 the login
page, which no TurnstileType field can cover because form_login handles the POST itself. If
you hit pixelopen/cloudflare-turnstile-bundle#2
on your login form, protect_password_login is the fix.
Step 1: Replace the package
composer remove pixelopen/cloudflare-turnstile-bundle composer require vuillaume-agency/symfony-turnstile
Step 2: Update bundles.php (this is probably automatically done by the composer remove and require)
// config/bundles.php // Remove: // PixelOpen\CloudflareTurnstileBundle\PixelOpenCloudflareTurnstileBundle::class => ['all' => true], // Add: VuillaumeAgency\TurnstileBundle\VuillaumeAgencyTurnstileBundle::class => ['all' => true],
Step 3: Delete old config
rm config/packages/pixel_open_cloudflare_turnstile.yaml
Step 4: Update imports in your code
Find and replace in your project:
| Old | New |
|---|---|
PixelOpen\CloudflareTurnstileBundle\Type\TurnstileType |
VuillaumeAgency\TurnstileBundle\Type\TurnstileType |
PixelOpen\CloudflareTurnstileBundle\Validator\CloudflareTurnstile |
VuillaumeAgency\TurnstileBundle\Validator\CloudflareTurnstile |
# Quick find/replace with sed (Linux/macOS) find src -name "*.php" -exec sed -i '' 's/PixelOpen\\CloudflareTurnstileBundle/VuillaumeAgency\\TurnstileBundle/g' {} +
Step 5: Clear cache
php bin/console cache:clear
Migrating from reCAPTCHA
Switching from Google reCAPTCHA is straightforward:
- Remove your reCAPTCHA bundle and configuration
- Install this bundle (see Installation)
- Replace
RecaptchaTypewithTurnstileTypein your forms - Remove reCAPTCHA from your cookie consent banner
- Update your privacy policy (simpler now!)
No changes needed in your controllers — validation works the same way.
Testing
During development, use Cloudflare's test credentials instead of real keys. This is the recommended approach as it keeps validation active while providing predictable behavior.
Test site keys
| Site key | Behavior |
|---|---|
1x00000000000000000000AA |
Always passes (recommended) |
2x00000000000000000000AB |
Always blocks |
3x00000000000000000000FF |
Forces an interactive challenge |
Test secret keys
| Secret key | Behavior |
|---|---|
1x0000000000000000000000000000000AA |
Always passes (recommended) |
2x0000000000000000000000000000000AA |
Always fails |
3x0000000000000000000000000000000AA |
Returns "token already spent" |
Example dev configuration
# .env.local (for development) TURNSTILE_KEY="1x00000000000000000000AA" TURNSTILE_SECRET="1x0000000000000000000000000000000AA"
Alternative: Disabling validation
You can also disable validation entirely, but using test keys is preferred as it keeps your code paths consistent between environments:
# config/packages/dev/vuillaume_agency_turnstile.yaml vuillaume_agency_turnstile: enable: false
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes
- Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
Please ensure tests pass (vendor/bin/phpunit) and code follows standards (vendor/bin/php-cs-fixer fix).
License
The MIT License (MIT). See LICENSE for more information.