ryangjchandler/laravel-cloudflare-turnstile/turnstile-development
Claude Code and agent skill by ryangjchandler/laravel-cloudflare-turnstile
- community
Checking that the SKILL.md file is still in the repository.
Protect forms with Cloudflare Turnstile using ryangjchandler/laravel-cloudflare-turnstile — the scripts and widget Blade components, the validation rule, Livewire binding, translations, and faking the challenge in tests.
Install
npx vskill@latest install ryangjchandler/laravel-cloudflare-turnstile/turnstile-developmentSKILL.md
Turnstile Development
When to use this skill
Use this skill when the application has ryangjchandler/laravel-cloudflare-turnstile installed and the task involves keeping bots out of a form: contact forms, registration, newsletter sign-ups, comment boxes, password reset — anything a visitor can submit without being logged in.
How it works
Cloudflare's script renders a widget in the browser. When the visitor clears the challenge, the widget writes a token into a hidden cf-turnstile-response input inside its own container. That token is submitted with the form, and the server exchanges it with Cloudflare's siteverify endpoint. A token is valid once and expires after roughly five minutes.
There is no config file to publish. The package reads two values straight from config/services.php:
Cloudflare publishes test keys that always pass or always fail — use those for local development rather than disabling the widget.
Rules
- Never verify a token twice.
siteverifyconsumes it, so the second attempt
comes back timeout-or-duplicate. Whenever a submit is rejected — for a Turnstile failure or for an unrelated field error — the visitor needs a fresh widget before they can retry.
- The widget only renders on script load. Cloudflare's
api.jsscans for
.cf-turnstile when it loads and does not watch the DOM afterwards. A container that appears later (a Livewire morph, an @if flipping back, a modal opened after page load) stays blank until you call window.turnstile.render() yourself. Keep the widget mounted, or start over with a full page load.
- **Do not put the widget behind
wire:ignoreyourself when using
wire:model** — the component already adds it, along with the callbacks it needs. A second wire:ignore is harmless; a competing data-callback is not.
- The secret must never reach the page. Only
services.turnstile.keyis
rendered; nothing in this package outputs the secret.
- Validate on the server, always. The widget is a client-side gate. Without
the rule it stops nobody.
Setting it up
Load the script once, in the <head> of the layout:
The component passes its attributes onto the <script> tag, so a CSP nonce or an onload handler goes there:
Then drop the widget inside the form:
And verify the token it submits:
Widget options
Every client-side rendering parameter passes straight through as an attribute:
class is merged, not replaced, so class="mt-4" keeps cf-turnstile intact.
Livewire
Bind the token to a property with wire:model. The component then switches into Livewire mode: it marks the container wire:ignore so morphs leave it alone, registers {id}Callback / {id}ExpiredCallback globals, and emits a small script that pushes the token into the property and resets the widget when the property is cleared.
- The `id` must match `/^[a-zA-Z_][a-zA-Z0-9_]*$/` — letters, digits and
underscores only, never starting with a digit. It is not an HTML id; it names the callback globals. Give each widget on a page its own, or they overwrite each other's callbacks. Anything else throws InvalidArgumentException while rendering.
- Clear the property on every rejected submit. The emitted script watches the
property and calls window.turnstile.reset() when it goes from filled to empty, which is what hands the visitor a usable widget again:
```php public string $turnstileToken = '';
public function submit(): void { try { $validated = $this->validate([ // 'bail' keeps a missing token from also being reported as a // rejected one, which would print two messages on one field. ...$this->getRules(), 'turnstileToken' => ['bail', 'required', new Turnstile], ]); } catch (ValidationException $e) { // Tokens are single-use; clearing the property resets the widget. $this->reset('turnstileToken');
throw $e; }
// ... } ```
Validate the challenge in the same pass as the rest of the form. Checking it only after the other fields pass is tempting — it saves a round trip to Cloudflare when someone fats-fingers an e-mail address — but it means an empty form reports every field except the challenge, so the one control the visitor has no error next to is the one silently blocking them. getRules() returns the component's own rules, including those declared with #[Validate], so merging is a one-liner.
- A success screen that replaces the form cannot simply be toggled back. The
re-inserted container never renders (see the rules above), so send the visitor through a fresh request instead of wire:click="$set('sent', false)":
``blade <a href="{{ route('contact') }}" class="btn">Send another message</a> ``
Error messages
The rule reports Cloudflare's error codes through the cloudflare-turnstile::errors translation namespace. Override them — to translate, or to replace Cloudflare's wording with something a visitor can act on — by publishing or hand-writing lang/vendor/cloudflare-turnstile/{locale}/errors.php:
| Key | Means | |
|---|---|---|
missing-input-secret, invalid-input-secret | The app is misconfigured — the visitor can do nothing about it. | |
missing-input-response | No token was submitted; the visitor skipped the widget. | |
invalid-input-response, timeout-or-duplicate | The token expired or was already used. Ask them to try again. | |
bad-request, internal-error, unexpected | Transient; ask them to try again later. |
Views can be overridden the same way, with --tag=cloudflare-turnstile-views.
Testing
Swap the client for a fake so tests never call Cloudflare:
Turnstile::dummy() returns a placeholder token to submit:
Without a fake, the rule performs a real HTTP request — a test suite that forgets one is slow, flaky, and dependent on the network. If a test asserts that the challenge is required rather than that it fails, no fake is needed: the required rule short-circuits before siteverify runs.
Operational notes
Client::siteverify()retries three times, 100ms apart. If Cloudflare is still
unreachable, the rule lets the submission through rather than locking every visitor out of the form. Anything that must not fail open needs its own check on top.
- The client is registered as a scoped binding, so it resolves once per
request and reads services.turnstile.secret at that moment. Tests that change the config must do so before anything resolves the client.
- Rate limiting and Turnstile solve different problems. A cleared challenge says
"probably a human", not "not abusive" — keep the throttle on the route.
Security
- Security scanPassed, 100 of 100Pattern checks on the skill's files.
- Model reviewPassed
Quality evaluation
- Task evaluationNot runNo evaluation yet. Run one in Skill Studio.
Source
- SKILL.md in the repositoryCheckingresources/boost/skills/turnstile-development/SKILL.md
Popularity
- Repository stars
- 464
- Repository forks
- 33
- 7-day trend score
- 31
Stars and forks are for the whole GitHub repository, not this skill alone. Not refreshed yet.
Works with
Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.
- Claude Code
- Cursor
- GitHub Copilot
- Windsurf
- Codex
- Gemini CLI
- Cline
- Amp
- Roo Code
- Goose
- Continue
- Kilo Code
- Junie
- Trae
- OpenHands
- Qwen Code
- AI Agent
- Tabnine Chat
- Sourcegraph Cody
- Amazon Q
- Firebase Genkit
- Sweep
- Devin
- Phind
- Mentat
- Claude Desktop
- Aider
- GPT Pilot
- Plandex
- bolt.new
- v0
- Replit Agent
- Anse
- TypingMind
- BetterChatGPT
- LibreChat
- Warp AI
- Pieces
- Fig AI
Embed badge
[](https://verified-skill.com/skills/ryangjchandler/laravel-cloudflare-turnstile/turnstile-development)