pixelopen/cloudflare-turnstile-bundle
PixelOpen Cloudflare Turnstile Bundle integrates Cloudflare Turnstile into Symfony forms, offering a privacy-friendly CAPTCHA alternative with simple configuration and server-side token verification to protect submissions from bots and spam.
Since this is a Symfony bundle, a Laravel developer's realistic path is to treat it as a reference implementation and/or port the pattern into a Laravel service — not to composer require it into a Laravel app (it wires into Symfony's form/validator/DI stack and won't play nicely with Laravel's container or Blade).
Steps to evaluate and use it:
readonly classes, readonly properties and constructor property promotion. If your Laravel app (or an internal package you're publishing) still supports 8.1, don't paste the classes verbatim — downgrade readonly class to plain classes with typed properties.src/ tree: look for DependencyInjection/PixelOpenCloudflareTurnstileExtension.php (config keys), DependencyInjection/Configuration.php (the tree itself), the compiler pass PixelOpenCloudflareTurnstileCompilerPass, the validator/constraint that calls siteverify, and the form type (TurnstileType) that renders the widget. Those files contain 90% of what you need to replicate.enable (singular — confirmed in 0.5.0 release notes), so expect YAML roughly like pixel_open_cloudflare_turnstile: { enable: true, site_key: ..., secret_key: ... }. Double-check key names against Configuration.php — they have shifted between versions, and the tree is the source of truth, not the README.https://challenges.cloudflare.com/turnstile/v0/siteverify on submit.TurnstileRule or TurnstileService that does the form-encoded POST to siteverify with secret, response, and optionally remoteip. Register a Blade directive or component that emits the <div class="cf-turnstile" data-sitekey="…"> plus the JS loader.Server-side constraint pattern (Symfony source to mirror). The bundle attaches a validator to the TurnstileType form field. The validator reads the token from the request, POSTs it to siteverify with the secret, and throws a validation failure on success: false. In Laravel this becomes a Rule class used inside a FormRequest:
Turnstile::verify($request->input('cf-turnstile-response'), $request->ip());
Widget rendering. The client-side piece is always: load https://challenges.cloudflare.com/turnstile/v0/api.js (async/defer), then place <div class="cf-turnstile" data-sitekey="…"></div>. Cloudflare injects a hidden input named cf-turnstile-response. Your form simply submits that field. Do not rename it or you'll spend an hour debugging "invalid-input-response." The bundle's TurnstileType is worth reading as a checklist of the attributes it exposes (data-action, data-cdata, theme, size, callback).
Binding tokens to specific forms (use this). Pass action and optionally cdata when rendering the widget, and verify both server-side. Without this, a token obtained on one form (e.g. newsletter) can be replayed on a more sensitive form (e.g. password reset). The bundle exposes these via the form type/Twig helper; in a port, add them as arguments to your Blade component.
Testing / CI pattern. Cloudflare provides dummy keys you should use in APP_ENV=test:
1x00000000000000000000AA — always passes2x00000000000000000000AA — always fails (widget visible)3x00000000000000000000AA — forces interactive challengeFor unit tests, don't hit Cloudflare: bind a fake HTTP client. The bundle uses Symfony's HTTP client; the Laravel equivalent is Http::fake(). 0.5.0 added unit tests for TurnstileType — read them as a template for asserting your rendered widget markup (sitekey present, hidden field name correct, action/cdata attributes emitted).
Extension points in the Symfony bundle worth copying. A custom TurnstileVerifier service you can swap (useful for logging, retries, circuit-breakers), and the config-driven enable flag so you can disable the challenge locally without code changes. Add a TURNSTILE_ENABLED toggle to your Laravel port for the same reason — but validate it strictly (see Gotchas).
Translations as a UX reference. 0.5.0 ships German, Italian and Spanish translations. Don't port them file-for-file; instead note which user-facing strings the bundle treats as translatable (challenge failure, widget label) and make sure your Laravel port resolves those through __() / lang/ rather than hardcoding English in the Rule's message().
Layout of a Laravel adaptation (typical shape):
config/services.php — turnstile.site_key, secret_key, enabledApp\Services\Turnstile — verify(?string $token, ?string $ip = null): boolApp\Rules\Turnstile — wraps the service, exposes message()resources/views/components/turnstile.blade.php — sitekey + script tagresources/js/app.js — one-time turnstile.ready(…) if you need callbacks (the script auto-renders by default)Token is single-use and short-lived (~300s). Validate it on the submit request; never cache it, never verify twice. If your app has AJAX validation that fires the same token, you'll get a false "invalid-input-response" on the second call.
success: false is not always "user failed the challenge." Check error-codes in the JSON body before surfacing a message:
missing-input-secret / invalid-input-secret — env var not loaded, wrong key, or trailing whitespace.missing-input-response — hidden input not submitted; usually a renamed field or the widget didn't render.invalid-input-response — token expired, already used, or secret key mismatch.timeout-or-duplicate — same as above; retries need a fresh token.
Returning a generic "Please refresh and try again" for these is correct; logging the code helps you debug.siteverify is application/x-www-form-urlencoded, not JSON. The most common porting bug is Http::post($url, ['secret' => …, 'response' => …]) — Laravel sends JSON by default. You need Http::asForm()->post(...). This is exactly what the bundle does under the hood in its Symfony HTTP client call.
The enable flag is now a strict boolean node. 0.5.0 switched from scalarNode to booleanNode. In the bundle, enable: "true", enable: 1 or enable: "1" will now fail config validation — only real YAML booleans (true/false) pass. In your Laravel port, mirror that strictness: (bool) env('TURNSTILE_ENABLED', true) is fine, but env('TURNSTILE_ENABLED') returning the string "false" is truthy — cast explicitly, or a staging deploy with TURNSTILE_ENABLED=false silently keeps the challenge on (or off, depending on your cast).
Config classes are now final. Configuration, PixelOpenCloudflareTurnstileCompilerPass and PixelOpenCloudflareTurnstileExtension are closed for extension as of 0.5.0. If you were planning to subclass the extension to inject a custom verifier or extra config node, you can't — use service decoration/tagging instead. Same applies if you copied those classes into a port and added subclasses.
PHP 8.2 is now the floor. Dropping 7.4 is a hard break for anyone vendoring the source into an older app. It also means the code you read uses readonly class and promoted constructor properties — syntactically invalid on PHP 8.1. Check composer.json ("php": ">=8.2") before assuming a snippet compiles in your project.
Check hostname and action on the response for any form that matters. hostname confirms the token came from your domain; action confirms it came from the right form. The bundle's validator is lax about this by default — tighten it in your own implementation.
remoteip is optional but recommended. Pass the client IP; behind a load balancer ensure TrustProxies is configured in Laravel, or you'll pass the balancer's IP. Cloudflare's own proxies are already in challenges.cloudflare.com's network, so the IP is about your visitor, not Cloudflare's edge.
Don't leak the secret key. It belongs in env, never in a config file that's committed or in the Blade template. The site key is meant to be in the HTML — don't mistake one for the other.
Test keys bypass the challenge entirely. That's the point. If you use 1x000… key in staging and forget to switch for production, every form passes and you'll never know your validation broke. Log success=false in production so failures are visible.
Config key names in this bundle are version-sensitive. The 0.5.0 churn (strict boolean node, final classes, PHP floor bump) confirms the config surface is still moving. If the README's YAML doesn't match, read Configuration.php and PixelOpenCloudflareTurnstileExtension.php before assuming the bundle is broken. Same rule for the constraint class name.
Dependency injection & testing in Symfony. The validator pulls the secret from the container at runtime — if you write integration tests that bind a mocked validator, mock the HTTP client (the bundle depends on symfony/http-client), not the validator itself, to keep the siteverify code path real.
Bundle registration on Symfony Flex. With Flex, bundles.php auto-updates; if not, add PixelOpen\CloudflareTurnstileBundle\CloudflareTurnstileBundle::class => ['all' => true] manually. Easy to miss and shows up as "service not found."
Extension points worth replicating. The Symfony bundle exposes the verifier as a service you can decorate (decoration, not subclassing — the DI classes are final now). Mirror this in Laravel with a bound interface (TurnstileContract) so you can swap in a logging/retrying verifier later without touching controllers.
Not a Laravel package. There's no config/turnstile.php, no service provider auto-discovery for Laravel, no Rule class targeting Laravel's validator. Budget a half day to port the ~200 lines that matter; don't try to make the bundle work inside Laravel unless you already run Symfony components and accept the friction.
How can I help you explore Laravel packages today?