├── lang └── en │ ├── validation.php │ └── interstitial.php ├── src ├── Contracts │ └── TurnstileException.php ├── Enums │ ├── SecretKey.php │ └── SiteKey.php ├── Exceptions │ └── InvalidChallengeException.php ├── Facades │ └── Turnstile.php ├── Views │ └── Components │ │ ├── Widget.php │ │ └── Script.php ├── Http │ ├── Middleware │ │ ├── TurnstileMiddlewareDefinition.php │ │ ├── InterstitialMiddleware.php │ │ └── TurnstileMiddleware.php │ ├── Controllers │ │ └── InterstitialController.php │ └── Requests │ │ └── TurnstileRequest.php ├── TurnstileServiceProvider.php ├── Validation │ └── TurnstileRule.php ├── Challenge.php └── Turnstile.php ├── LICENSE.md ├── composer.json ├── config └── turnstile.php ├── resources └── views │ └── interstitial.blade.php └── README.md /lang/en/validation.php: -------------------------------------------------------------------------------- 1 | 'The Cloudflare Turnstile challenge is invalid, absent, or has failed.', 5 | ]; 6 | -------------------------------------------------------------------------------- /src/Contracts/TurnstileException.php: -------------------------------------------------------------------------------- 1 | 'Checking for secure connection', 5 | 'description' => 'Your browser will be automatically redirected', 6 | ]; 7 | -------------------------------------------------------------------------------- /src/Enums/SecretKey.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | public static function collect(): Collection 19 | { 20 | return new Collection(self::cases()); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Enums/SiteKey.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | public static function collect(): Collection 21 | { 22 | return new Collection(self::cases()); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidChallengeException.php: -------------------------------------------------------------------------------- 1 | challenge->errors) . '.'); 18 | } 19 | 20 | /** 21 | * Returns the offending challenge. 22 | */ 23 | public function getChallenge(): Challenge 24 | { 25 | return $this->challenge; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Italo Israel Baeza Cabrera 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Facades/Turnstile.php: -------------------------------------------------------------------------------- 1 | turnstile->isDisabled()) { 29 | return ''; 30 | } 31 | 32 | return function (): string { 33 | $siteKey = match(Str::studly($this->siteKey)) { 34 | 'VisiblePassing' => SiteKey::VisiblePassing->value, 35 | 'VisibleBlocks' => SiteKey::VisibleBlocks->value, 36 | 'InvisiblePassing' => SiteKey::InvisiblePassing->value, 37 | 'InvisibleBlocks' => SiteKey::InvisibleBlocks->value, 38 | 'ForceInteraction' => SiteKey::ForceInteraction->value, 39 | default => $this->turnstile->getSiteKey() 40 | }; 41 | 42 | return "
merge(['class' => 'cf-turnstile', 'data-sitekey' => '$siteKey']) }}>
"; 43 | }; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Http/Middleware/TurnstileMiddlewareDefinition.php: -------------------------------------------------------------------------------- 1 | 1) { 32 | $guards = func_get_args(); 33 | } 34 | 35 | $this->guards = implode('&', Arr::wrap($guards)); 36 | 37 | return $this; 38 | } 39 | 40 | /** 41 | * The input name in the request where the Cloudflare Turnstile response resides. 42 | * 43 | * @return $this 44 | */ 45 | public function input(string $name): static 46 | { 47 | $this->input = $name; 48 | 49 | return $this; 50 | } 51 | 52 | /** 53 | * Makes an additional check for the action name of the Turnstile Challenge. 54 | * 55 | * @return $this 56 | */ 57 | public function action(string $action): static 58 | { 59 | $this->action = $action; 60 | 61 | return $this; 62 | } 63 | 64 | /** 65 | * Accept failed challenges that are no server-errors. 66 | * 67 | * @return $this 68 | */ 69 | public function acceptFailed(): static 70 | { 71 | $this->failed = 'true'; 72 | 73 | return $this; 74 | } 75 | 76 | /** 77 | * Returns the object instance as a string. 78 | * 79 | * @return string 80 | */ 81 | public function __toString(): string 82 | { 83 | return 'turnstile:' . implode(',', [ 84 | $this->input, 85 | $this->guards, 86 | $this->action, 87 | $this->failed, 88 | ]); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Views/Components/Script.php: -------------------------------------------------------------------------------- 1 | turnstile->isDisabled()) { 42 | return fn() => ''; 43 | } 44 | 45 | return function (): string { 46 | $source = static::SOURCE . $this->query(); 47 | 48 | $script = << 50 | HTML; 51 | 52 | if ($this->meta) { 53 | $script .= "\n" . << 55 | HTML; 56 | 57 | } 58 | 59 | return $script; 60 | }; 61 | } 62 | 63 | /** 64 | * Generate the query parameters if the script has been set with custom attributes. 65 | */ 66 | protected function query(): string 67 | { 68 | $query = []; 69 | 70 | if ($this->explicit) { 71 | $query['render'] = 'explicit'; 72 | } 73 | 74 | if ($this->onload) { 75 | $query['onload'] = $this->onload; 76 | } 77 | 78 | return $query ? '?'.http_build_query($query) : ''; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laragear/turnstile", 3 | "description": "Use Cloudflare's no-CAPTCHA alternative in your Laravel application.", 4 | "type": "library", 5 | "license": "MIT", 6 | "minimum-stability": "dev", 7 | "prefer-stable": true, 8 | "keywords": [ 9 | "laragear", 10 | "turnstile", 11 | "captcha", 12 | "cloudflare", 13 | "http2", 14 | "http3" 15 | ], 16 | "authors": [ 17 | { 18 | "name": "Italo Israel Baeza Cabrera", 19 | "email": "darkghosthunter@gmail.com", 20 | "homepage": "https://github.com/sponsors/DarkGhostHunter" 21 | } 22 | ], 23 | "support": { 24 | "source": "https://github.com/Laragear/Turnstile", 25 | "issues": "https://github.com/Laragear/Turnstile/issues" 26 | }, 27 | "require": { 28 | "php": "^8.2", 29 | "ext-json": "*", 30 | "illuminate/support": "11.*|12.*", 31 | "illuminate/http": "11.*|12.*", 32 | "illuminate/routing": "11.*|12.*", 33 | "illuminate/container": "11.*|12.*", 34 | "illuminate/events": "11.*|12.*", 35 | "illuminate/session": "11.*|12.*" 36 | }, 37 | "require-dev": { 38 | "orchestra/testbench": "9.*|10.*" 39 | }, 40 | "autoload": { 41 | "psr-4": { 42 | "Laragear\\Turnstile\\": "src" 43 | } 44 | }, 45 | "autoload-dev": { 46 | "psr-4": { 47 | "Tests\\": "tests" 48 | } 49 | }, 50 | "scripts": { 51 | "test": "vendor/bin/phpunit", 52 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage" 53 | }, 54 | "config": { 55 | "sort-packages": true 56 | }, 57 | "extra": { 58 | "laravel": { 59 | "providers": [ 60 | "Laragear\\Turnstile\\TurnstileServiceProvider" 61 | ], 62 | "aliases": { 63 | "Turnstile": "Laragear\\Turnstile\\Facades\\Turnstile" 64 | } 65 | } 66 | }, 67 | "funding": [ 68 | { 69 | "type": "Github Sponsorship", 70 | "url": "https://github.com/sponsors/DarkGhostHunter" 71 | }, 72 | { 73 | "type": "Paypal", 74 | "url": "https://paypal.me/darkghosthunter" 75 | } 76 | ] 77 | } 78 | -------------------------------------------------------------------------------- /src/TurnstileServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom(static::CONFIG, 'turnstile'); 28 | 29 | $this->loadTranslationsFrom(static::LANG, 'turnstile'); 30 | $this->loadViewsFrom(static::VIEWS, 'turnstile'); 31 | 32 | $this->app->singleton(Turnstile::class); 33 | 34 | // Remove the challenge when the application lifecycle ends. 35 | $this->app->terminating(static function (Application $app) { 36 | unset($app[Challenge::class]); 37 | }); 38 | } 39 | 40 | /** 41 | * Bootstrap the application services. 42 | */ 43 | public function boot(Router $router): void 44 | { 45 | $router->aliasMiddleware( 46 | Http\Middleware\TurnstileMiddleware::ALIAS, Http\Middleware\TurnstileMiddleware::class 47 | ); 48 | $router->aliasMiddleware( 49 | Http\Middleware\InterstitialMiddleware::ALIAS, Http\Middleware\InterstitialMiddleware::class 50 | ); 51 | 52 | if ($this->app->runningInConsole()) { 53 | $this->publishes([static::CONFIG => $this->app->configPath('turnstile.php')], 'config'); 54 | $this->publishes([static::LANG => $this->app->langPath('vendor/turnstile')], 'lang'); 55 | $this->publishes([static::VIEWS => $this->app->resourcePath('vendor/turnstile')], 'views'); 56 | } 57 | 58 | $this->callAfterResolving('blade.compiler', static function (BladeCompiler $blade): void { 59 | $blade->componentNamespace('Laragear\\Turnstile\\Views\\Components', 'turnstile'); 60 | }); 61 | 62 | $this->callAfterResolving('validator', static function (ValidatorFactory $validator, Application $app): void { 63 | $validator->extendImplicit( 64 | Validation\TurnstileRule::NAME, 65 | Validation\TurnstileRule::class, 66 | $app->make('translator')->get('turnstile::validation.invalid') 67 | ); 68 | }); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Http/Controllers/InterstitialController.php: -------------------------------------------------------------------------------- 1 | middleware(function (Request $request, Closure $next): mixed { 30 | // If the Request already contains the challenge, bail out. 31 | $duration = $request->session()->get($this->config->get('turnstile.interstitial.key')); 32 | 33 | if ($duration === true || $this->date->createFromTimestamp((int) $duration)->isFuture()) { 34 | return $this->redirect->intended(); 35 | } 36 | 37 | return $next($request); 38 | }); 39 | } 40 | 41 | /** 42 | * Show the interstitial form. 43 | */ 44 | public function show(Repository $config, Factory $viewFactory): View 45 | { 46 | return $viewFactory->make($config->get('turnstile.interstitial.view')); 47 | } 48 | 49 | /** 50 | * Receive the interstitial challenge, and allow the user to continue. 51 | */ 52 | public function allow(TurnstileRequest $request): RedirectResponse 53 | { 54 | $duration = $this->config->get('turnstile.interstitial.duration'); 55 | 56 | // Add the session key with the timestamp for when the challenge should be forgotten. 57 | $request->session()->put( 58 | $this->config->get('turnstile.interstitial.key'), 59 | $duration === true ?: $this->date->now()->addMinutes($duration)->getTimestamp() 60 | ); 61 | 62 | return $this->redirect->intended(); 63 | } 64 | 65 | /** 66 | * Registers a default route for the controller to work. 67 | */ 68 | public static function register(string $path = 'turnstile/interstitial', string|array $middleware = []): Route 69 | { 70 | $route = RouteFacade::get($path) 71 | ->uses([InterstitialController::class, 'show']) 72 | ->name(Config::get('turnstile.interstitial.route')) 73 | ->middleware($middleware); 74 | 75 | RouteFacade::post($path)->uses([InterstitialController::class, 'allow'])->middleware($middleware); 76 | 77 | return $route; 78 | } 79 | } 80 | 81 | -------------------------------------------------------------------------------- /src/Http/Middleware/InterstitialMiddleware.php: -------------------------------------------------------------------------------- 1 | shouldSkipWhenAuthenticated($auth) || $this->shouldSkipWhenChallengeRecentlyDone($request)) { 42 | return $next($request); 43 | } 44 | 45 | $route = $this->config->get('turnstile.interstitial.route'); 46 | 47 | if ($request->expectsJson()) { 48 | $this->throwJsonException($route); 49 | } 50 | 51 | return $this->redirect->setIntendedUrl($request->fullUrl())->route($route); 52 | } 53 | 54 | /** 55 | * Check if the user is authenticated when there is an auth parameter. 56 | */ 57 | protected function shouldSkipWhenAuthenticated(string $auth): bool 58 | { 59 | foreach ($auth === 'auth' ? [null] : Str::of($auth)->after('=')->explode('&') as $guard) { 60 | if ($this->auth->guard($guard)->check()) { 61 | return true; 62 | } 63 | } 64 | 65 | return false; 66 | } 67 | 68 | /** 69 | * Check if the challenge was recently successful. 70 | */ 71 | protected function shouldSkipWhenChallengeRecentlyDone(Request $request): bool 72 | { 73 | $duration = $request->session()->get($this->config->get('turnstile.interstitial.key'), 0); 74 | 75 | return $duration === true || $this->date->createFromTimestamp($duration)->isFuture(); 76 | } 77 | 78 | /** 79 | * Throw a JSON exception with some data for the turnstile challenge. 80 | */ 81 | protected function throwJsonException(string $route): never 82 | { 83 | throw new HttpResponseException( 84 | new JsonResponse([ 85 | 'success' => false, 86 | 'message' => 'Requires Turnstile Challenge.', 87 | 'redirect_url' => $this->redirect->route($route)->getTargetUrl(), 88 | ], 400) 89 | ); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /config/turnstile.php: -------------------------------------------------------------------------------- 1 | env('TURNSTILE_ENV'), 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Key 23 | |-------------------------------------------------------------------------- 24 | | 25 | | By default, the library will check for the Cloudflare Turnstile response 26 | | token using this key name in the Request. If you have a custom frontend 27 | | that uses another key name, you can change this default key name here. 28 | | 29 | */ 30 | 31 | 'key' => \Laragear\Turnstile\Turnstile::KEY, 32 | 33 | /* 34 | |-------------------------------------------------------------------------- 35 | | HTTP Client Options 36 | |-------------------------------------------------------------------------- 37 | | 38 | | This array is passed down to the underlying HTTP Client which will make 39 | | the request to Turnstile servers. By default, it will use HTTP/1.1 for 40 | | the request. You can change, remove or add more options in the array. 41 | | 42 | | @see https://docs.guzzlephp.org/en/stable/request-options.html 43 | */ 44 | 45 | 'client' => [ 46 | \GuzzleHttp\RequestOptions::VERSION => 1.1, // You may test 3.0 in your platform. 47 | ], 48 | 49 | /* 50 | |-------------------------------------------------------------------------- 51 | | Credentials 52 | |-------------------------------------------------------------------------- 53 | | 54 | | This holds the site key (frontend) and secret key (backend). The site key 55 | | will be used to generate the challenge token, and the secret key will be 56 | | used to retrieve the challenge result from Turnstile from your backend. 57 | | 58 | */ 59 | 60 | 'site_key' => env('TURNSTILE_SITE_KEY'), 61 | 'secret_key' => env('TURNSTILE_SECRET_KEY'), 62 | 63 | /* 64 | |-------------------------------------------------------------------------- 65 | | Interstitial Middleware 66 | |-------------------------------------------------------------------------- 67 | | 68 | | When using the Interstitial middleware this config will handle the view 69 | | that will be shown for the challenge, the controller that will receive 70 | | the response token, and the session key name to store the completion. 71 | | 72 | | When "global" is set to true, it will be registered site-wide. 73 | | 74 | */ 75 | 76 | 'interstitial' => [ 77 | 'key' => '_turnstile.interstitial', 78 | 'view' => 'turnstile::interstitial', 79 | 'route' => 'turnstile.interstitial', 80 | 'duration' => true, 81 | ], 82 | ]; 83 | -------------------------------------------------------------------------------- /src/Validation/TurnstileRule.php: -------------------------------------------------------------------------------- 1 | turnstile->isDisabled()) { 45 | return true; 46 | } 47 | 48 | [$guards, $acceptFailed] = $this->parseParameters($parameters); 49 | 50 | if ($this->userIsAuthenticated($guards)) { 51 | return true; 52 | } 53 | 54 | return $this->hasToken($value) 55 | && ($this->turnstile->getChallenge($value, $this->request->ip())->successful || $acceptFailed); 56 | } 57 | 58 | /** 59 | * Parse the incoming rule parameters. 60 | * 61 | * @param string[] $parameters 62 | * @return array{0: string[], 1: bool} 63 | */ 64 | protected function parseParameters(array $parameters): array 65 | { 66 | $parameters = array_pad($parameters, 2, 'null'); 67 | 68 | return [ 69 | $this->normalizeGuards($this->findAuthParameter($parameters)), 70 | in_array('accept-failed', $parameters, true), 71 | ]; 72 | } 73 | 74 | /** 75 | * Find the parameter that has the auth configuration. 76 | * 77 | * @param string[] $parameters 78 | */ 79 | protected function findAuthParameter(array $parameters): ?string 80 | { 81 | return Arr::first($parameters, static function (?string $parameter): bool { 82 | return Str::startsWith($parameter, 'auth'); 83 | }); 84 | } 85 | 86 | /** 87 | * Normalizes the guards from the auth parameter. 88 | * 89 | * @return array{0: null}|string[] 90 | */ 91 | protected function normalizeGuards(?string $auth): array 92 | { 93 | return match ($auth) { 94 | null => [], 95 | 'auth' => [null], 96 | default => Str::of($auth)->after('=')->explode(',')->toArray(), 97 | }; 98 | } 99 | 100 | /** 101 | * Check if the user is authenticated on any given guard on the parameters. 102 | */ 103 | protected function userIsAuthenticated(array $guards): bool 104 | { 105 | foreach ($guards as $guard) { 106 | if ($this->auth->guard($guard)->check()) { 107 | return true; 108 | } 109 | } 110 | 111 | return false; 112 | } 113 | 114 | /** 115 | * Check if the rule should run based on the guards and value. 116 | */ 117 | protected function hasToken(mixed $value): bool 118 | { 119 | return (is_string($value) || $value instanceof Stringable) && !empty((string) $value); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Http/Middleware/TurnstileMiddleware.php: -------------------------------------------------------------------------------- 1 | turnstile->key(); 56 | 57 | $shouldContinue = $this->turnstile->isDisabled() 58 | || $this->bypassOnAuth(explode(',', $guards)) 59 | || $this->challengeSuccessful($request, $key, $action) 60 | || Str::lower($acceptFailed) === 'true'; 61 | 62 | return $shouldContinue 63 | ? $next($request) 64 | : throw ValidationException::withMessages([$key => $this->lang->get('turnstile::validation.invalid')]); 65 | } 66 | 67 | /** 68 | * Check if the user is authenticated by the given guards. 69 | * 70 | * @param string[] $guards 71 | */ 72 | protected function bypassOnAuth(array $guards): bool 73 | { 74 | foreach ($guards as $guard) { 75 | if ($this->auth->guard($guard === 'null' ? null : $guard)->check()) { 76 | return true; 77 | } 78 | } 79 | 80 | return false; 81 | } 82 | 83 | /** 84 | * Retrieves and checks if the Turnstile Challenge is successful. 85 | * 86 | * @throws \Illuminate\Contracts\Container\BindingResolutionException 87 | * @throws \Illuminate\Http\Client\ConnectionException 88 | * @throws \Illuminate\Http\Client\RequestException 89 | */ 90 | protected function challengeSuccessful(Request $request, string $key, string $action): bool 91 | { 92 | $token = $request->input($key); 93 | 94 | if (is_string($token) && $token !== '') { 95 | $challenge = $this->turnstile->getChallenge($token, $request->ip()); 96 | 97 | return $challenge->successful 98 | && (empty($action) || $challenge->isAction($action)); 99 | } 100 | 101 | return false; 102 | } 103 | 104 | /** 105 | * Dynamically call methods to the underlying Turnstile Middleware Definition. 106 | */ 107 | public static function __callStatic(string $name, array $arguments): TurnstileMiddlewareDefinition 108 | { 109 | return (new TurnstileMiddlewareDefinition())->{$name}(...$arguments); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Http/Requests/TurnstileRequest.php: -------------------------------------------------------------------------------- 1 | checkTurnstileChallenge(); 17 | 18 | parent::validateResolved(); 19 | } 20 | 21 | /** 22 | * Checks if the Cloudflare Turnstile challenge is valid through the validation rule. 23 | * 24 | * @internal 25 | * @throws \Illuminate\Contracts\Container\BindingResolutionException 26 | */ 27 | protected function checkTurnstileChallenge(): void 28 | { 29 | // Avoid depleting the challenge response if is precognitive. 30 | if ($this->isPrecognitive() && $this->skipChallengeWhenPrecognitive()) { 31 | return; 32 | } 33 | 34 | /** @var \Laragear\Turnstile\Turnstile $turnstile */ 35 | $turnstile = $this->container->make(Turnstile::class); 36 | 37 | $key = $this->getTurnstileKey() ?: $turnstile->key(); 38 | 39 | // Create a new validator with overridable the messages and attribute names. 40 | $this->container->make('validator')->make( 41 | $this->only($key), 42 | [$key => $this->getTurnstileRules() ?: $turnstile->rules()], 43 | $this->messages(), 44 | $this->attributes(), 45 | )->validate(); 46 | } 47 | 48 | /** 49 | * Returns the default Turnstile Response token key to find in the request. When falsy, the default will be used. 50 | * 51 | * @return void|string 52 | */ 53 | protected function getTurnstileKey() 54 | { 55 | // ... 56 | } 57 | 58 | /** 59 | * Returns the rules that will be used against the Turnstile Response token. When falsy, the defaults will be used. 60 | * 61 | * @return void|string|array<\Illuminate\Contracts\Validation\ValidationRule|string> 62 | */ 63 | protected function getTurnstileRules() 64 | { 65 | // ... 66 | } 67 | 68 | /** 69 | * If the Precognitive Request should check for the Turnstile Challenge. 70 | */ 71 | protected function skipChallengeWhenPrecognitive(): bool 72 | { 73 | return true; 74 | } 75 | 76 | /** 77 | * Returns the received Challenge data from Cloudflare Turnstile servers. 78 | * 79 | * @throws \Illuminate\Contracts\Container\BindingResolutionException 80 | */ 81 | public function challenge(): Challenge 82 | { 83 | return $this->container->make(Challenge::class); 84 | } 85 | 86 | /** 87 | * Returns a metadata value using a key in `dot.notation`. 88 | * 89 | * @throws \Illuminate\Contracts\Container\BindingResolutionException 90 | */ 91 | public function metadata(string $key, mixed $default = null): mixed 92 | { 93 | return $this->challenge()->metadata($key, $default); 94 | } 95 | 96 | /** 97 | * Check if the action is the same as the developer expects. 98 | * 99 | * @throws \Illuminate\Contracts\Container\BindingResolutionException 100 | */ 101 | public function isAction(string $action): bool 102 | { 103 | return $this->challenge()->isAction($action); 104 | } 105 | 106 | /** 107 | * Check if the action is not the same as the developer expects. 108 | * 109 | * @throws \Illuminate\Contracts\Container\BindingResolutionException 110 | */ 111 | public function isNotAction(string $action): bool 112 | { 113 | return $this->challenge()->isNotAction($action); 114 | } 115 | 116 | /** 117 | * Checks if the Customer Data is the same pattern as the developer expects. 118 | * 119 | * @param string|iterable $customerData 120 | * @throws \Illuminate\Contracts\Container\BindingResolutionException 121 | */ 122 | public function isCustomerData(string|iterable $customerData): bool 123 | { 124 | return $this->challenge()->isCustomerData($customerData); 125 | } 126 | 127 | /** 128 | * Checks if the Customer Data is not the same pattern as the developer expects. 129 | * 130 | * @param string|iterable $customerData 131 | * @throws \Illuminate\Contracts\Container\BindingResolutionException 132 | */ 133 | public function isNotCustomerData(string|iterable $customerData): bool 134 | { 135 | return $this->challenge()->isNotCustomerData($customerData); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/Challenge.php: -------------------------------------------------------------------------------- 1 | hasError( 47 | 'missing-input-secret', 48 | 'invalid-input-secret', 49 | 'missing-input-response', 50 | 'invalid-input-response' 51 | ); 52 | } 53 | 54 | /** 55 | * Check if there is an error due to backend manipulation (bad request, duplicated token). 56 | */ 57 | public function isBackendError(): bool 58 | { 59 | return $this->hasError('bad-request', 'timeout-or-duplicated'); 60 | } 61 | 62 | /** 63 | * Check if there is an error due to Turnstile (anything else). 64 | */ 65 | public function isServerError(): bool 66 | { 67 | return $this->hasError('internal-error'); 68 | } 69 | 70 | /** 71 | * Returns a metadata value using a key in `dot.notation`. 72 | */ 73 | public function metadata(string $key, mixed $default = null): mixed 74 | { 75 | return Arr::get($this->metadata, $key, $default); 76 | } 77 | 78 | /** 79 | * Check if the action is the same as the developer expects. 80 | */ 81 | public function isAction(string $action): bool 82 | { 83 | return $this->action === $action; 84 | } 85 | 86 | /** 87 | * Check if the action is not the same as the developer expects. 88 | */ 89 | public function isNotAction(string $action): bool 90 | { 91 | return ! $this->isAction($action); 92 | } 93 | 94 | /** 95 | * Returns the Customer Data as a Stringable instance. 96 | */ 97 | public function strOfCustomerData(): Stringable 98 | { 99 | return new Stringable($this->customerData); 100 | } 101 | 102 | /** 103 | * Checks if the Customer Data is the same pattern as the developer expects. 104 | * 105 | * @param string|iterable $customerData 106 | */ 107 | public function isCustomerData(string|iterable $customerData): bool 108 | { 109 | return $this->strOfCustomerData()->is($customerData); 110 | } 111 | 112 | /** 113 | * Checks if the Customer Data is not the same pattern as the developer expects. 114 | * 115 | * @param string|iterable $customerData 116 | */ 117 | public function isNotCustomerData(string|iterable $customerData): bool 118 | { 119 | return !$this->isCustomerData($customerData); 120 | } 121 | 122 | /** 123 | * Checks if the response has any of the given errors. 124 | */ 125 | public function hasError(string ...$errors): bool 126 | { 127 | if (!$errors) { 128 | throw new InvalidArgumentException('The errors array must not be empty.'); 129 | } 130 | 131 | foreach ($errors as $error) { 132 | if (in_array($error, $this->errors, true)) { 133 | return true; 134 | } 135 | } 136 | 137 | return false; 138 | } 139 | 140 | /** 141 | * Checks if the response has none of the given errors. 142 | */ 143 | public function missingError(string...$errors): bool 144 | { 145 | return !$this->hasError(...$errors); 146 | } 147 | 148 | /** 149 | * Dynamically retrieve the object properties. 150 | * 151 | * @throws \ErrorException 152 | */ 153 | public function __get(string $name): CarbonInterface|string|bool 154 | { 155 | return match ($name) { 156 | 'success' => $this->successful, 157 | 'failed', 'fail' => !$this->successful, 158 | 'cdata', 'cData', 'customer_data', 'c_data' => $this->customerData, 159 | 'solved_at' => $this->solvedAt, 160 | default => throw new ErrorException('Undefined property: ' . static::class . '::$' . $name), 161 | }; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/Turnstile.php: -------------------------------------------------------------------------------- 1 | testingSiteKey = $key; 88 | 89 | return $this; 90 | } 91 | 92 | /** 93 | * USe the given Testing Secret Key for retrieving challenges from the backend. 94 | * @return $this 95 | */ 96 | public function useTestingSecretKey(SecretKey $key): static 97 | { 98 | $this->testingSecretKey = $key; 99 | 100 | return $this; 101 | } 102 | 103 | /** 104 | * Returns the default key to use to check Cloudflare Turnstile responses. 105 | */ 106 | public function key(): string 107 | { 108 | return $this->config->get('turnstile.key'); 109 | } 110 | 111 | /** 112 | * Returns the default rule to check in a Validation object. 113 | */ 114 | public function rule(): string 115 | { 116 | return Validation\TurnstileRule::NAME; 117 | } 118 | 119 | /** 120 | * Returns the array of rules to be added to a Validation rules array. 121 | * 122 | * @return array{string: string} 123 | */ 124 | public function rules(): array 125 | { 126 | return [$this->key() => $this->rule()]; 127 | } 128 | 129 | /** 130 | * Check if Turnstile should be enabled for all purposes. 131 | */ 132 | public function isEnabled(): bool 133 | { 134 | return $this->currentEnvironment() !== false; 135 | } 136 | 137 | /** 138 | * Check if Turnstile should be disabled for all purposes. 139 | */ 140 | public function isDisabled(): bool 141 | { 142 | return !$this->isEnabled(); 143 | } 144 | 145 | /** 146 | * Returns the current environment for Turnstile. 147 | */ 148 | protected function currentEnvironment(): string|false 149 | { 150 | return $this->config->get('turnstile.env') ?? $this->container->make('env'); 151 | } 152 | 153 | /** 154 | * Retrieves a Turnstile challenge. 155 | * 156 | * @throws \Illuminate\Http\Client\ConnectionException 157 | * @throws \Illuminate\Http\Client\RequestException 158 | * @throws \Illuminate\Contracts\Container\BindingResolutionException 159 | */ 160 | public function getChallenge( 161 | string $token = '', 162 | string $ip = '', 163 | string $idempotencyKey = '', 164 | bool $save = true, 165 | array $options = [], 166 | ): Challenge { 167 | $challenge = $this->parseChallengeResponse( 168 | $this->getResponse($token, $ip, $idempotencyKey, $options) 169 | ); 170 | 171 | // If there are server errors, or it's our fault, bail out. 172 | if ($challenge->isServerError() || $challenge->isBackendError()) { 173 | throw new InvalidChallengeException($challenge); 174 | } 175 | 176 | if ($save) { 177 | $this->container->instance(Challenge::class, $challenge); 178 | } 179 | 180 | return $challenge; 181 | } 182 | 183 | /** 184 | * Retrieves the Challenge response from Turnstile servers. 185 | * 186 | * @throws \Illuminate\Http\Client\ConnectionException 187 | * @throws \Illuminate\Http\Client\RequestException 188 | */ 189 | protected function getResponse(string $token, string $ip, string $idempotencyKey, array $options): Response 190 | { 191 | if ($this->shouldFake()) { 192 | $this->http->fake($this->createFakeResponse()); 193 | } 194 | 195 | return $this->http 196 | ->asJson() 197 | ->acceptJson() 198 | ->withOptions(array_merge($this->config->get('turnstile.client'), $options)) 199 | ->post(static::ENDPOINT, array_filter([ 200 | 'secret' => $this->getSecretKey(), 201 | 'response' => $token, 202 | 'remoteip' => $ip, 203 | 'idempotency_key' => $idempotencyKey, 204 | ])) 205 | ->throw(); 206 | } 207 | 208 | /** 209 | * Parses the incoming Cloudflare Turnstile Siteverify HTTP Response into a Challenge instance. 210 | */ 211 | protected function parseChallengeResponse(Response $response): Challenge 212 | { 213 | return new Challenge( 214 | $response->json('success', false), 215 | $response->json('hostname', 'localhost'), 216 | $response->json('action', ''), 217 | $response->json('cdata', ''), 218 | $response->json('metadata', []), 219 | $response->json('errors', []), 220 | $this->date->createFromFormat( 221 | static::DATETIME_FORMAT, 222 | $response->json('challenge_ts', $this->date->now()->format(static::DATETIME_FORMAT)) 223 | ), 224 | ); 225 | } 226 | 227 | /** 228 | * Returns the Challenge using the defaults and with the current Request instance. 229 | * 230 | * @throws \Illuminate\Http\Client\ConnectionException 231 | * @throws \Illuminate\Http\Client\RequestException 232 | * @throws \Psr\Container\ContainerExceptionInterface 233 | * @throws \Psr\Container\NotFoundExceptionInterface 234 | */ 235 | public function getChallengeFromRequest( 236 | ?Request $request = null, 237 | string $key = '', 238 | string $idempotencyKey = '', 239 | array $options = [], 240 | bool $save = true, 241 | ): Challenge { 242 | $request ??= $this->container->get('request'); 243 | 244 | return $this->getChallenge( 245 | $request->input($key ?: $this->key()), $request->ip(), $idempotencyKey, $save, $options, 246 | ); 247 | } 248 | 249 | /** 250 | * Fakes the challenge to be retrieved from Cloudflare Turnstile servers. 251 | * 252 | * @param array{success?: bool, hostname?: string, action?: string, cdata?: string, metadata?: array, errors?: array} $response 253 | * @return $this 254 | */ 255 | public function fake(array $response = ['success' => true]): static 256 | { 257 | $this->shouldFake = true; 258 | $this->fakedResponse = $response; 259 | 260 | return $this; 261 | } 262 | 263 | /** 264 | * Check if the response should be faked. 265 | */ 266 | protected function shouldFake(): bool 267 | { 268 | return $this->shouldFake || in_array($this->currentEnvironment(), ['testing', false]); 269 | } 270 | 271 | /** 272 | * Returns the Site Key for Cloudflare Turnstile. 273 | */ 274 | public function getSiteKey(): string 275 | { 276 | $key = $this->config->get('turnstile.site_key'); 277 | 278 | if ($this->currentEnvironment() !== 'production') { 279 | $key = match (strtolower($key)) { 280 | '' => $this->testingSiteKey->value, 281 | strtolower(SiteKey::VisiblePassing->name) => SiteKey::VisiblePassing->value, 282 | strtolower(SiteKey::VisibleBlocks->name) => SiteKey::VisibleBlocks->value, 283 | strtolower(SiteKey::InvisiblePassing->name) => SiteKey::InvisiblePassing->value, 284 | strtolower(SiteKey::InvisibleBlocks->name) => SiteKey::InvisibleBlocks->value, 285 | default => $key, 286 | }; 287 | } 288 | 289 | return $key; 290 | } 291 | 292 | /** 293 | * Return the Secret Key for Cloudflare Turnstile. 294 | */ 295 | protected function getSecretKey(): string 296 | { 297 | $key = $this->config->get('turnstile.secret_key', ''); 298 | 299 | if ($this->currentEnvironment() !== 'production') { 300 | $key = match (strtolower($key)) { 301 | '' => $this->testingSecretKey->value, 302 | strtolower(SecretKey::Passing->name) => SecretKey::Passing->value, 303 | strtolower(SecretKey::Fails->name) => SecretKey::Fails->value, 304 | strtolower(SecretKey::Spent->name) => SecretKey::Spent->value, 305 | default => $key, 306 | }; 307 | } 308 | 309 | return $key; 310 | } 311 | 312 | /** 313 | * Retrieves an already stored Turnstile Challenge. 314 | * 315 | * @throws \Illuminate\Contracts\Container\BindingResolutionException 316 | */ 317 | public function challenge(): Challenge 318 | { 319 | return $this->hasChallenge() 320 | ? $this->container->make(Challenge::class) 321 | : throw new BindingResolutionException('The Turnstile Challenge has not been stored in the container.'); 322 | } 323 | 324 | /** 325 | * Check if the Turnstile Challenge has been saved into the Container. 326 | */ 327 | public function hasChallenge(): bool 328 | { 329 | return $this->container->bound(Challenge::class); 330 | } 331 | 332 | /** 333 | * Check if the Turnstile Challenge is not saved inside the Container. 334 | */ 335 | public function missingChallenge(): bool 336 | { 337 | return !$this->hasChallenge(); 338 | } 339 | 340 | /** 341 | * Removes the Challenge from the Container. 342 | */ 343 | public function flushChallenge(): void 344 | { 345 | unset($this->container[Challenge::class]); // @phpstan-ignore-line 346 | } 347 | 348 | /** 349 | * Fake a response to be received as challenge response. 350 | * 351 | * @return \Illuminate\Http\Client\ResponseSequence[] 352 | */ 353 | protected function createFakeResponse(): array 354 | { 355 | $date = $this->date->now()->subSecond()->format(static::DATETIME_FORMAT); 356 | $hostname = parse_url($this->config->get('app.url'), PHP_URL_HOST) ?: gethostname(); 357 | 358 | // The first challenge should be faked. The next ones should be duplicate error. 359 | return [ 360 | 'challenges.cloudflare.com/turnstile/v0/siteverify' => (new ResponseSequence([])) 361 | ->push( 362 | array_merge([ 363 | 'success' => true, 364 | 'hostname' => $hostname, 365 | 'challenge_ts' => $date, 366 | ], $this->fakedResponse), 367 | ) 368 | ->whenEmpty( 369 | Create::promiseFor( 370 | new Psr7Response(200, ['Content-Type' => 'application/json'], json_encode(array_merge([ 371 | 'hostname' => $hostname, 372 | 'action' => '', 373 | 'cdata' => '', 374 | 'metadata' => [], 375 | ], $this->fakedResponse, [ 376 | 'success' => false, 377 | 'errors' => ['timeout-or-duplicate'], 378 | 'challenge_ts' => $date, 379 | ]))), 380 | ), 381 | ), 382 | ]; 383 | } 384 | } 385 | -------------------------------------------------------------------------------- /resources/views/interstitial.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ __('turnstile::interstitial.title') }} 6 | 9 | 342 | 343 | 344 | 345 |
346 |
347 |
348 |
349 |
350 | {{ __('turnstile::interstitial.title') }} 351 |
352 |
353 | @csrf 354 |
355 | 356 |
357 | @error(\Laragear\Turnstile\Turnstile::KEY) 358 |
359 | The challenge is invalid. Try again or 360 | refresh the page. 361 |
362 | @enderror 363 |
364 |

365 | {{ __('turnstile::interstitial.description') }} 366 |

367 |
368 |
369 |
370 |
371 | 372 | 373 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Turnstile 2 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/laragear/turnstile.svg)](https://packagist.org/packages/laragear/turnstile) 3 | [![Latest stable test run](https://github.com/Laragear/Turnstile/workflows/Tests/badge.svg)](https://github.com/Laragear/Turnstile/actions) 4 | [![Codecov coverage](https://codecov.io/gh/Laragear/Turnstile/branch/1.x/graph/badge.svg?token=5U6BJUEA4T)](https://codecov.io/gh/Laragear/Turnstile) 5 | [![Maintainability](https://qlty.sh/badges/c82a8142-06a9-4700-8eee-b6bab1e69087/maintainability.svg)](https://qlty.sh/gh/Laragear/projects/Turnstile) 6 | [![Sonarcloud Status](https://sonarcloud.io/api/project_badges/measure?project=Laragear_Turnstile&metric=alert_status)](https://sonarcloud.io/dashboard?id=Laragear_Turnstile) 7 | [![Laravel Octane Compatibility](https://img.shields.io/badge/Laravel%20Octane-Compatible-success?style=flat&logo=laravel)](https://laravel.com/docs/11.x/octane#introduction) 8 | 9 | Use Cloudflare's no-CAPTCHA alternative in your Laravel application. 10 | 11 | ```php 12 | use Illuminate\Support\Facades\Route; 13 | 14 | Route::post('login', function () { 15 | // ... 16 | })->middleware('turnstile'); 17 | ``` 18 | 19 | ## Become a sponsor 20 | 21 | [![](.github/assets/support.png)](https://github.com/sponsors/DarkGhostHunter) 22 | 23 | Your support allows me to keep this package free, up-to-date and maintainable. Alternatively, you can **spread the word on social media!**. 24 | 25 | ## Requirements 26 | 27 | * Laravel 11 or later 28 | 29 | ## Installation 30 | 31 | You can install the package via Composer: 32 | 33 | ```bash 34 | composer require laragear/turnstile 35 | ``` 36 | 37 | ## Setup 38 | 39 | This library comes already with the **official demonstration keys** to start developing your application with Cloudflare Turnstile immediately. 40 | 41 | Once in **production**, you will require real keys, both of them obtainable through your [Cloudflare Dashboard](https://dash.cloudflare.com), and set as [environment variables](#credentials): 42 | 43 | ```dotenv 44 | TURNSTILE_SITE_KEY=... 45 | TURNSTILE_SECRET_KEY=... 46 | ``` 47 | 48 | ## Frontend integration 49 | 50 | This library comes with two [Blade Components](https://laravel.com/docs/12.x/blade#components) to easy your development pain: `` and ``. 51 | 52 | ### Script 53 | 54 | You can use the `` Blade Component to implement the Cloudflare Turnstile script in your `` tag of your HTML view. 55 | 56 | ```blade 57 | 58 | 59 | 60 | 61 | My application 62 | 63 | 64 | 65 | 66 | ... 67 | 68 | 69 | ``` 70 | 71 | The script will render a ` 75 | ``` 76 | 77 | If you don't want to use `async` or `defer`, you can set any of these to `false`. 78 | 79 | ```blade 80 | 81 | ``` 82 | 83 | ```html 84 | 85 | ``` 86 | 87 | You may also set `explicit` to `true` to make widgets be rendered only explicitly by your frontend JavaScript. 88 | 89 | ```blade 90 | 91 | ``` 92 | 93 | ```html 94 | 95 | ``` 96 | 97 | Finally, you can also set a custom callback name to be executed once the script is completely loaded in your frontend, especially if you're using explicit rendering, with the `onload` attribute. 98 | 99 | ```blade 100 | 101 | ``` 102 | 103 | ```html 104 | 105 | ``` 106 | 107 | #### Site Key on JavaScript frontend 108 | 109 | If you put the script on the `` part of your HTML view, you may set the `meta` attribute to render a `` tag alongside the script. This tag will contain your Turnstile site key so your JavaScript frontend can retrieve use it to render the widget. 110 | 111 | ```blade 112 | 113 | 114 | 115 | 116 | My application 117 | 118 | 119 | 120 | 121 | ... 122 | 123 | 124 | ``` 125 | 126 | It will render the following HTML: 127 | 128 | ```html 129 | 130 | 131 | ``` 132 | 133 | You will be able to retrieve the site key through Javascript by querying the meta tag with the `turnstile-sitekey` name. 134 | 135 | ```vue 136 | 145 | 146 | 151 | ``` 152 | 153 | Alternatively, you may set a custom name for the tag by setting a value to the `meta` attribute. 154 | 155 | ```blade 156 | 157 | ``` 158 | 159 | ```html 160 | 161 | ``` 162 | 163 | ### Widget 164 | 165 | > [!IMPORTANT] 166 | > 167 | > Remember that the Widget Mode is controlled via your [Cloudflare Dashboard](https://dash.cloudflare.com), not here. On development, this is controlled with [testing keys](#testing-keys). 168 | 169 | You can use the `` Blade Component to add the Turnstile Widget in your forms. Depending on the Widget Mode, the Widget may render as usual or be invisible at Turnstile discretion. 170 | 171 | ```blade 172 |
173 | @csrf 174 | 175 | 176 | 177 | 178 | 179 | 182 | 183 | ``` 184 | 185 | You can pass HTML attributes and [data attributes](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/) to change the widget behavior. For example, you can use the `data-action` to differentiate multiple widgets in your application, or `data-error-callback` to execute a JavaScript function in your frontend if the challenge fails. 186 | 187 | ```bladehtml 188 | 189 | ``` 190 | 191 | ```html 192 |
198 | ``` 199 | 200 | > [!TIP] 201 | > 202 | > Classes are automatically appended, so you shouldn't worry about overwriting the `cf-turnstile` class used by the Widget to render. 203 | 204 | ## Backend integration 205 | 206 | When issuing a form, you have three alternatives to ensure the Turnstile challenge is valid and successful, from the easiest to the more flexible: 207 | 208 | - Use the [`TurnstileRequest` request](#validating-with-request) on the controller action. 209 | - Use the [`turnstile` middleware](#validating-with-middleware) on the route. 210 | - Use the [`turnstile` rule](#validating-with-rule) on the Request validation. 211 | - Manually [retrieve the Challenge](#validating-manually). 212 | 213 | > [!WARNING] 214 | > 215 | > All methods will fail on server-side errors: 216 | > 217 | > - The Cloudflare Turnstile servers are unreachable. 218 | > - The request to Cloudflare Turnstile servers is malformed. 219 | > - The token is duplicated or had a timeout. 220 | > 221 | > Connection problems will always throw an exception. 222 | 223 | ### Validating with Request 224 | 225 | The easiest and least intrusive way to check the Turnstile Challenge is to use the `Laragear\Turnstile\Http\Requests\TurnstileRequest` instance in your controller. This is great if you only have a few controllers where you want to stop bots. 226 | 227 | ```php 228 | use App\Models\Comment; 229 | use Illuminate\Support\Facades\Route; 230 | use Laragear\Turnstile\Http\Requests\TurnstileRequest; 231 | 232 | Route::post('comment', function (TurnstileRequest $request) { 233 | $request->validate([ 234 | 'body' => 'required|string' 235 | ]); 236 | 237 | return Comment::create($request->only('body')); 238 | }) 239 | ``` 240 | 241 | You can have access to the Cloudflare Turnstile Challenge object through the `challenge()` method, plus additional helpers for the Challenge instance itself. For example, you may use it to double-check if the action is equal to something you expect. 242 | 243 | ```php 244 | use App\Models\Comment; 245 | use Illuminate\Support\Facades\Route; 246 | use Laragear\Turnstile\Http\Requests\TurnstileRequest; 247 | 248 | Route::post('comment', function (TurnstileRequest $request) { 249 | $request->validate([ 250 | 'body' => 'required|string' 251 | ]); 252 | 253 | if ($request->isAction('comment:store')) { 254 | return back()->withErrors('Invalid action'); 255 | } 256 | 257 | return Comment::create($request->only('body')); 258 | }) 259 | ``` 260 | 261 | > [!IMPORTANT] 262 | > 263 | > The Request will check for the `cf-turnstile-response` key [by default](#form-key), plus a successful Challenge. If you need more fine-tuning, you may [extend the form request class](#extending-the-form-request). Alternatively, you may also use a [middleware](#validating-with-middleware), [rule](#validating-with-rule), or [validate manually](#validating-manually). 264 | 265 | #### Extending the Form Request 266 | 267 | If you need to create a form request and also validate the Turnstile Challenge, you may safely extend the `TurnstileRequest` instead of the base `FormRequest`. The class runs the validation _before_ your form request authorization and rules to avoid running side effects. 268 | 269 | ```php 270 | namespace App\Http\Requests; 271 | 272 | use Laragear\Turnstile\Http\Requests\TurnstileRequest; 273 | 274 | class CommentStoreRequest extends TurnstileRequest 275 | { 276 | public function rules(): array 277 | { 278 | return [ 279 | 'body' => 'required|string' 280 | ]; 281 | } 282 | } 283 | ``` 284 | 285 | This means your controller can safely retrieve the validated data using `$request->validated()`, as the token won't be considered part of the Request itself. 286 | 287 | ```php 288 | use App\Models\Comment; 289 | use Illuminate\Support\Facades\Route; 290 | use App\Http\Requests\CommentStoreRequest; 291 | 292 | Route::post('comment', function (CommentStoreRequest $request) { 293 | return Comment::create($request->validated()); 294 | }) 295 | ``` 296 | 297 | #### Custom key and rules 298 | 299 | You may also edit the key and the rules where to find and check the Response Token in the request. For the case of rules, ensure you're using [the `turnstile` rule](#validating-with-rule). 300 | 301 | ```php 302 | namespace App\Http\Requests; 303 | 304 | use Laragear\Turnstile\Http\Requests\TurnstileRequest; 305 | 306 | class CommentStoreRequest extends TurnstileRequest 307 | { 308 | public function getTurnstileKey() 309 | { 310 | return '_cf-custom-key'; 311 | } 312 | 313 | protected function getTurnstileRules() 314 | { 315 | // Skip if the user is authenticated. 316 | return 'turnstile:auth' 317 | } 318 | 319 | // ... 320 | } 321 | ``` 322 | 323 | #### Precognitive request 324 | 325 | The `TurnstileRequest` won't check for the Challenge Token on [Precognitive Requests](https://laravel.com/docs/12.x/precognition), which is useful to not disrupt [live-validation](https://laravel.com/docs/12.x/precognition#live-validation). 326 | 327 | If you require custom validation on precognitive requests, you may override the `skipChallengeWhenPrecognitive()` method. 328 | 329 | ```php 330 | namespace App\Http\Requests; 331 | 332 | use Laragear\Turnstile\Http\Requests\TurnstileRequest; 333 | 334 | class CommentStoreRequest extends TurnstileRequest 335 | { 336 | protected function skipChallengeWhenPrecognitive(): bool 337 | { 338 | return $this->hasHeader('Requires-CF-Turnstile-Challenge'); 339 | } 340 | 341 | // ... 342 | } 343 | ``` 344 | 345 | #### Extending the Form Request 346 | 347 | If you need to create a form request and also validate the Turnstile Challenge, you may safely extend the `TurnstileRequest` instead. The class runs the validation _before_ your form request authorization and rules. 348 | 349 | ```php 350 | namespace App\Http\Requests; 351 | 352 | use Laragear\Turnstile\Http\Requests\TurnstileRequest; 353 | 354 | class CommentStoreRequest extends TurnstileRequest 355 | { 356 | public function rules(): array 357 | { 358 | return [ 359 | 'body' => 'required|string' 360 | ]; 361 | } 362 | } 363 | ``` 364 | 365 | This means your controller can safely retrieve the validated data using `$request->validated()`. 366 | 367 | ```php 368 | use App\Models\Comment; 369 | use Illuminate\Support\Facades\Route; 370 | use App\Http\Requests\CommentStoreRequest; 371 | 372 | Route::post('comment', function (CommentStoreRequest $request) { 373 | return Comment::create($request->validated()); 374 | }) 375 | ``` 376 | 377 | ### Validating with Middleware 378 | 379 | The `turnstile` middleware is a great way to check if a form submission contains a successful challenge. Simply add the middleware to the route (or group of routes) that receive the form submission, like a `POST`, `PUT` or `PATCH`. 380 | 381 | ```php 382 | use Illuminate\Http\Request; 383 | use Illuminate\Support\Facades\Route; 384 | 385 | Route::post('comment', function (Request $request) { 386 | // ... 387 | })->middleware('turnstile'); 388 | ``` 389 | 390 | > [!NOTE] 391 | > 392 | > Is not suggested to use the middleware on `GET` methods or similar. Some browsers (or extensions) may _cache_ or _inspect_ ahead document links. 393 | 394 | If you want to configure the middleware behaviour, you should use the `TurnstileMiddleware` class and the static helper methods. 395 | 396 | ```php 397 | use Illuminate\Http\Request; 398 | use Illuminate\Support\Facades\Route; 399 | use Laragear\Turnstile\Http\Middleware\TurnstileMiddleware; 400 | 401 | Route::post('comment', function (Request $request) { 402 | // ... 403 | })->middleware(TurnstileMiddleware::acceptFailed()) 404 | ``` 405 | 406 | #### Custom challenge key 407 | 408 | The middleware will check for the `cf-turnstile-response` key set in the form or JSON, [by default](#form-key). If you have edited your frontend to use another key, use the `input()` method of the middleware class with the key name. 409 | 410 | ```php 411 | use Illuminate\Http\Request; 412 | use Illuminate\Support\Facades\Route; 413 | use Laragear\Turnstile\Http\Middleware\TurnstileMiddleware; 414 | 415 | Route::post('comment', function (Request $request) { 416 | // ... 417 | })->middleware(TurnstileMiddleware::input('my-response-token-key')) 418 | ``` 419 | 420 | #### Middleware bypass when authenticated 421 | 422 | You can configure the authentication guards to bypass the challenge requirement if the user is authenticated through the `auth()` method. 423 | 424 | ```php 425 | use Illuminate\Http\Request; 426 | use Illuminate\Support\Facades\Route; 427 | use Laragear\Turnstile\Http\Middleware\TurnstileMiddleware; 428 | 429 | Route::post('comment', function (Request $request) { 430 | // ... 431 | })->middleware(TurnstileMiddleware::auth()); 432 | ``` 433 | 434 | By default, it will check the default authentication guard of your application. You may set specific guards by just naming them. 435 | 436 | ```php 437 | use Laragear\Turnstile\Http\Middleware\TurnstileMiddleware; 438 | 439 | TurnstileMiddleware::auth('admin'); 440 | ``` 441 | 442 | To complement this, you should add the [widget](#widget) to your forms only if user is a guest for the given guards. 443 | 444 | ```blade 445 |
446 | 447 | 448 | 449 | @guest('admin') 450 | 451 | @endguest 452 | 453 | 456 | 457 | ``` 458 | 459 | #### Middleware accepts failed challenges 460 | 461 | You can allow the route to continue even if the challenge failed using the `acceptFailed()` method. 462 | 463 | ```php 464 | use Illuminate\Http\Request; 465 | use Illuminate\Support\Facades\Route; 466 | use Laragear\Turnstile\Http\Middleware\TurnstileMiddleware; 467 | 468 | Route::post('comment', function (Request $request) { 469 | // ... 470 | })->middleware(TurnstileMiddleware::acceptFailed()); 471 | ``` 472 | 473 | #### Middleware checks action 474 | 475 | If you have multiple Cloudflare Turnstile widgets in your application, and you have separated them through actions names, you can add a check to match the action name in the backend. If the action doesn't match, a validation exception will be thrown. 476 | 477 | ```php 478 | use Illuminate\Http\Request; 479 | use Illuminate\Support\Facades\Route; 480 | use Laragear\Turnstile\Http\Middleware\TurnstileMiddleware; 481 | 482 | Route::post('comment', function (Request $request) { 483 | // ... 484 | })->middleware(TurnstileMiddleware::action('comment:store')); 485 | ``` 486 | 487 | #### Validating on Precognitive 488 | 489 | By default, the middleware will [skip running on Precognitive requests](https://laravel.com/docs/12.x/precognition#managing-side-effects). If you want to run it, set the `TurnstileMiddleware::onPrecognitive()` option, especially if your validation has side effects. 490 | 491 | ```php 492 | use Illuminate\Http\Request; 493 | use Illuminate\Support\Facades\Route; 494 | use Laragear\Turnstile\Http\Middleware\TurnstileMiddleware; 495 | 496 | Route::post('comment', function (Request $request) { 497 | // ... 498 | })->middleware(TurnstileMiddleware::onPrecognitive()); 499 | ``` 500 | 501 | 502 | ### Validating with Rule 503 | 504 | You can use the `turnstile` rule to check if the Turnstile challenge is present and is successful in the data to validate. The easiest way is to unpack the default rule contained in the `rules()` method of the `Turnstile` facade. 505 | 506 | ```php 507 | use Illuminate\Http\Request; 508 | use Laragear\Turnstile\Facades\Turnstile; 509 | 510 | public function create(Request $request) 511 | { 512 | $request->validate([ 513 | 'comment' => 'required|string', 514 | // 'cf-turnstile-response' => 'turnstile', 515 | ...Turnstile::rules() 516 | ]); 517 | 518 | // ... 519 | } 520 | ``` 521 | 522 | For more granular control, you can use the `key` method of the `Turnstile` facade to use the [default key](#form-key)that the Cloudflare Turnstile script injects into the form, and put your own additional validation rules if necessary. 523 | 524 | ```php 525 | use App\Rules\MyCustomRule; 526 | use Illuminate\Http\Request; 527 | use Laragear\Turnstile\Facades\Turnstile; 528 | 529 | public function create(Request $request) 530 | { 531 | $request->validate([ 532 | // ... 533 | Turnstile::key() => ['turnstile', new MyCustomRule], 534 | ]); 535 | 536 | // ... 537 | } 538 | ``` 539 | 540 | #### Rule bypass when authenticated 541 | 542 | If you want to bypass the rule check if the user is authenticated, set the `auth` parameter on the rule. 543 | 544 | ```php 545 | use Illuminate\Http\Request; 546 | use Laragear\Turnstile\Facades\Turnstile; 547 | 548 | public function create(Request $request) 549 | { 550 | $request->validate([ 551 | Turnstile::key() => 'turnstile:auth', 552 | ]); 553 | 554 | // ... 555 | } 556 | ``` 557 | 558 | You may also add a list of guards to check by adding them after `=` and separating them by `,`. 559 | 560 | ```php 561 | $request->validate([ 562 | Turnstile::key() => 'turnstile:auth=admin,developer', 563 | ]); 564 | ``` 565 | 566 | #### Rule accepts failed challenges 567 | 568 | The rule supports not checking if the challenge is successful by setting the `accept-failed` parameter. This can be useful to retrieve the response later and programmatically continue based on the response result through the `sucess()` and `failed()` methods of the `Turnstile` facade. 569 | 570 | ```php 571 | use Illuminate\Http\Request; 572 | use Laragear\Turnstile\Facades\Turnstile; 573 | 574 | public function create(Request $request) 575 | { 576 | $request->validate([ 577 | Turnstile::key() => 'required|turnstile:accept-failed' 578 | ]); 579 | 580 | if (Turnstile::success()) { 581 | // ... 582 | } 583 | } 584 | ``` 585 | 586 | ### Validating Manually 587 | 588 | > [!IMPORTANT] 589 | > 590 | > The challenge is automatically retrieved by the [request](#validating-with-request), [middleware](#validating-with-middleware) and [rule](#validating-with-rule). If that's case, you may [use the `challenge()` method](#retrieving-an-already-received-challenge) instead. 591 | 592 | To validate the Challenge manually, you require the Turnstile Response Token that is sent by the frontend, and optionally the IP of the Request. 593 | 594 | Once identified, you should use the `getChallenge()` method of `Turnstile` facade to retrieve the Challenge from Cloudflare Turnstile servers. 595 | 596 | You will receive a `Laragear\Turnstile\Challenge` instance with some useful helpers to check the challenge status. 597 | 598 | ```php 599 | use Illuminate\Http\Request; 600 | use Illuminate\Support\Facades\Route; 601 | use Laragear\Turnstile\Facades\Turnstile; 602 | 603 | Route::post('comment', function (Request $request) { 604 | $challenge = Turnstile::getChallenge( 605 | $request->input('cf-turnstile-response'), $request->ip() 606 | ); 607 | 608 | if ($challenge->failed || $challenge->isNotAction('comment:store')) { 609 | // ... throw an exception. 610 | } 611 | 612 | // ... save the comment. 613 | }) 614 | ``` 615 | 616 | Alternatively, if you're already using the default configuration, you can just use `getChallengeFromRequest()` which will automatically resolve the Request from the Container and find the token using the [default key name](#form-key). 617 | 618 | ```php 619 | use Laragear\Turnstile\Facades\Turnstile; 620 | 621 | $challenge = Turnstile::getChallengeFromRequest(); 622 | ``` 623 | 624 | Once the challenge is retrieved, is saved into the Application Container. This makes easier to retrieve the challenge elsewhere in your application. If you don't want to save the Challenge, set the `save` parameter to `false`. 625 | 626 | ```php 627 | use Laragear\Turnstile\Facades\Turnstile; 628 | 629 | $challenge = Turnstile::getChallenge('token', save: false); 630 | ``` 631 | 632 | ### Idempotency Keys 633 | 634 | Because Cloudflare Turnstile Siteverify API will return an error when retrieving the same Challenge more than once, an idempotency key can be used in case of duplicate submissions. 635 | 636 | How idempotency is handled will be up to your application. While most of the time is not needed at all, on some frontends the token may be resent anyway. To avoid errors, you can add a UUID string to both `getChallenge()` and `getChallengeFromRequest()` methods of the `Turnstile` facade. 637 | 638 | ```php 639 | use App\Uuid; 640 | use Illuminate\Http\Request; 641 | use Illuminate\Support\Facades\Route; 642 | use Laragear\Turnstile\Challenge; 643 | use Laragear\Turnstile\Facades\Turnstile; 644 | 645 | Route::post('comment', function (Request $request) { 646 | // Generate a UUID using the hash of the input as the seed. 647 | $uuid = Uuid::generateWithSeed(md5(json_encode($request->input('body'))); 648 | 649 | Turnstile::getChallengeFromRequest(idempotencyKey: $uuid); 650 | 651 | // ... 652 | }); 653 | ``` 654 | 655 | ### Getting the correct client IP 656 | 657 | If you're under a Cloudflare Proxy, can get the correct client IP through [the `CF-Connecting-IP` header](https://developers.cloudflare.com/fundamentals/reference/http-headers/#cf-connecting-ip). This is set as a constant in the `Turnstile` class, so you can use it when retrieving the challenge: 658 | 659 | ```php 660 | use App\Uuid; 661 | use Illuminate\Http\Request; 662 | use Illuminate\Support\Facades\Route; 663 | use Laragear\Turnstile\Challenge;use Laragear\Turnstile\Turnstile; 664 | 665 | Route::post('comment', function (Request $request, Turnstile $turnstile) { 666 | $challenge = $turnstile->getChallenge( 667 | $request->input($turnstile->key()), 668 | $request->header(Turnstile::HEADER) 669 | ); 670 | 671 | // ... 672 | }); 673 | ``` 674 | 675 | ### Retrieving the Challenge on failure 676 | 677 | If there is a server or backend error, the challenge retrieval will fail. If you still want to proceed, you may capture the exception and retrieve the Challenge with a try-catch block. 678 | 679 | ```php 680 | use Laragear\Turnstile\Exceptions\InvalidChallengeException;use Laragear\Turnstile\Facades\Turnstile; 681 | 682 | try { 683 | $challenge = Turnstile::getChallenge(); 684 | } catch (InvalidChallengeException $exception) { 685 | $challenge = $exception->getChallenge(); 686 | } 687 | ``` 688 | 689 | ## Retrieving an already received Challenge 690 | 691 | The `challenge()` method of the `Turnstile` facade can be used to retrieve an already saved Turnstile Challenge inside the Application Container. 692 | 693 | ```php 694 | use Laragear\Turnstile\Facades\Turnstile; 695 | 696 | $challenge = Turnstile::challenge(); 697 | ``` 698 | 699 | If you're not sure if the Challenge was received and saved, you can use both `hasChallenge()` and `missingChallenge()` beforehand. 700 | 701 | ```php 702 | use Laragear\Turnstile\Facades\Turnstile; 703 | 704 | if (Turnstile::missingChallenge()) { 705 | return Turnstile::getChallengeFromRequest(); 706 | } 707 | 708 | return Turnstile::challenge(); 709 | ``` 710 | 711 | Alternatively, you can use both `success()` and `failed()` methods to check if the challenge is successful or has failed, respectively. Of course, these must be invoked **after the challenge have been retrieved**. 712 | 713 | ```php 714 | use App\Models\Comment; 715 | use Illuminate\Http\Request; 716 | use Illuminate\Support\Facades\Route; 717 | use Laragear\Turnstile\Facades\Turnstile; 718 | 719 | Route::middleware('turnstile:accept-failed') 720 | ->post('comment', function (Request $request) { 721 | $request->validate([ 722 | 'body' => 'required|string', 723 | ]) 724 | 725 | $comment = Comment::make($request->only('body')); 726 | 727 | // If the challenge is successful, show the comment. 728 | if (Turnstile::success()) { 729 | $comment->approved_at = now(); 730 | } 731 | 732 | $comment->save(); 733 | 734 | return back(); 735 | }); 736 | ``` 737 | 738 | Finally, you can always inject the `Laragear\Tunrstile\Challenge` anywhere in your application. For example, in your route controller action. 739 | 740 | ```php 741 | use Illuminate\Http\Request; 742 | use Illuminate\Support\Facades\Route; 743 | use Laragear\Turnstile\Challenge; 744 | 745 | Route::middleware('turnstile') 746 | ->post('comment', function (Request $request, Challenge $challenge) { 747 | if ($challenge->isAction('comment:store')) { 748 | // .... 749 | } 750 | 751 | // ... 752 | }); 753 | ``` 754 | 755 | ## Interstitial challenge 756 | 757 | You can force a first-time visitor to complete a Turnstile challenge with the `turnstile.interstitial` middleware. Once completed, the user will be redirected to its intended route. 758 | 759 | Before using it, you should register the default routes to handle to show interstitial challenge and capture it. You can do this with the `Laragear\Turnstile\Http\Controllers\InterstitialController::register()` method. 760 | 761 | ```php 762 | use Illuminate\Support\Facades\Route; 763 | use Laragear\Turnstile\Http\Controllers\InterstitialController; 764 | 765 | Route::group(function () { 766 | // ... 767 | })->middleware('turnstile.interstitial'); 768 | 769 | InterstitialController::register(); 770 | ``` 771 | 772 | You may change the default path the routes will use using the first parameter, and middleware using the second: 773 | 774 | ```php 775 | use Laragear\Turnstile\Http\Controllers\InterstitialController; 776 | 777 | InterstitialController::register('/challenge', 'guest'); 778 | ``` 779 | 780 | > [!IMPORTANT] 781 | > 782 | > The interstitial middleware will _throw_ a JSON response if the request _requires_ JSON. This is because JSON response cannot be redirected. Instead, use the `redirect_url` key of the response to redirect the user to the interstitial controller. 783 | > 784 | > ```js 785 | > response = await $fetch('/comment', 'POST', {body: 'My Comment'}); 786 | > 787 | > if (response.isStatus(400) && response.hasJson('redirect_url')) { 788 | > window.location.href = response.json('redirect_url') 789 | > } 790 | > ``` 791 | 792 | ### Skip when authenticated 793 | 794 | If you want to skip the challenge if a user is authenticated, you may add the `auth` keyword as parameter. 795 | 796 | ```php 797 | use Illuminate\Support\Facades\Route; 798 | use Laragear\Turnstile\Http\Middleware\InterstitialMiddleware; 799 | 800 | Route::get('photos', function () { 801 | // .. 802 | })->middleware('turnstile.interstitial:auth'); 803 | ``` 804 | 805 | Alternatively, you can set which guards to check to skip the middleware by setting the guards as `auth=guard&guard..`. 806 | 807 | ```php 808 | use Illuminate\Support\Facades\Route; 809 | use Laragear\Turnstile\Http\Middleware\InterstitialMiddleware; 810 | 811 | Route::get('photos', function () { 812 | // .. 813 | })->middleware('turnstile.interstitial:auth=web&admins'); 814 | ``` 815 | 816 | > [!IMPORTANT] 817 | > 818 | > When setting the middleware to be skipped for authenticated users, [interstitial routes](#setup) should also be hidden for authenticated users. 819 | > 820 | > ```php 821 | > use Laragear\Turnstile\Http\Controllers\InterstitialController; 822 | > 823 | > InterstitialController::register(middleware: 'guest'); 824 | > ``` 825 | > 826 | 827 | ### Global interstitial 828 | 829 | If you want to [register the middleware globally](https://laravel.com/docs/12.x/middleware#global-middleware), you should do it in the `web` middleware group. This can be done in your `bootstrap/app.php` file or `App\Providers\AppServiceProviders`. 830 | 831 | ```php 832 | use Illuminate\Foundation\Application; 833 | use Illuminate\Foundation\Configuration\Middleware; 834 | 835 | return Application::configure(basePath: dirname(__DIR__)) 836 | ->withMiddleware(function (Middleware $middleware) { 837 | $middleware->appendToGroup('web', 'turnstile.interstitial'); 838 | }) 839 | ->create(); 840 | ``` 841 | 842 | ## Advanced configuration 843 | 844 | Laragear Turnstile is intended to work out-of-the-box, but you can publish the configuration file for fine-tuning the Challenge verification. 845 | 846 | ```bash 847 | php artisan vendor:publish --provider="Laragear\Turnstile\TurnstileServiceProvider" --tag="config" 848 | ``` 849 | 850 | You will get a config file with this array: 851 | 852 | ```php 853 | env('TURNSTILE_ENV', env('APP_ENV')), 857 | 'key' => \Laragear\Turnstile\Turnstile::KEY, 858 | 'client' => [ 859 | \GuzzleHttp\RequestOptions::VERSION => 1.1, 860 | ], 861 | 'site_key' => env('TURNSTILE_SITE_KEY'), 862 | 'secret_key' => env('TURNSTILE_SECRET_KEY'), 863 | 'interstitial' => [ 864 | 'key' => '_turnstile.interstitial', 865 | 'view' => 'turnstile::interstitial', 866 | 'route' => 'turnstile.interstitial', 867 | 'duration' => true, 868 | ], 869 | ]; 870 | ``` 871 | 872 | ### Environment 873 | 874 | ```php 875 | return [ 876 | 'env' => env('TURNSTILE_ENV'), 877 | ]; 878 | ``` 879 | 880 | This sets which environment the library should run as. When `null`, it will mirror your current application environment. 881 | 882 | - On `production`, you will require your Site Key and Secret Key from Cloudflare Turnstile. 883 | - On `testing`, challenges will be faked regardless, no keys needed. 884 | - On the rest of environments, [testing keys](#testing-keys) will be automatically injected. 885 | 886 | If you set `false` as the environment value, both [script](#script) and [widget](#widget) won't be rendered, and the [request](#validating-with-request), [middleware](#validating-with-middleware) and [rule](#validating-with-rule) won't retrieve challenges. 887 | 888 | > [!WARNING] 889 | > 890 | > When using [manual validation](#validating-manually) with the environment set as `false`, you will receive a successful fake Challenge. 891 | 892 | ### Form Key 893 | 894 | ```php 895 | return [ 896 | 'key' => \Laragear\Turnstile\Turnstile::KEY, 897 | ]; 898 | ``` 899 | 900 | This sets the default key to check for in the Request for the Turnstile response. By default, is `cf-turnstile-response`, but if you're using a custom frontend, you may change it here. 901 | 902 | ### HTTP Client options 903 | 904 | ```php 905 | return [ 906 | 'client' => [ 907 | \GuzzleHttp\RequestOptions::VERSION => 1.1, 908 | ], 909 | ]; 910 | ``` 911 | 912 | This array sets the options for the outgoing request to Cloudflare Turnstile servers. [This is handled by Guzzle](https://docs.guzzlephp.org/en/stable/request-options.html), which in turn will pass it to the underlying transport. Depending on your system, it will probably be cURL. 913 | 914 | By default, it instructs Guzzle to use HTTP/1.1, but [you can upgrade if available in your platform](#http2-or-http3-and-curl). 915 | 916 | ### Credentials 917 | 918 | ```php 919 | return [ 920 | 'site_key' => env('TURNSTILE_SITE_KEY'), 921 | 'secret_key' => env('TURNSTILE_SECRET_KEY'), 922 | ]; 923 | ``` 924 | 925 | Here is the full array of Turnstile Site Key (public) and Secret Key (private) to use. These can be obtained through your [Cloudflare Dashboard](https://dash.cloudflare.com). Do not change the array unless you know what you're doing. If you want to set your keys, use the environment variables instead: 926 | 927 | ```dotenv 928 | TURNSTILE_SITE_KEY=... 929 | TURNSTILE_SECRET_KEY=... 930 | ``` 931 | 932 | ### Interstitial 933 | 934 | ```php 935 | 'interstitial' => [ 936 | 'key' => '_turnstile.interstitial', 937 | 'view' => 'turnstile::interstitial', 938 | 'route' => 'turnstile.interstitial', 939 | 'duration' => true, 940 | ], 941 | ``` 942 | 943 | This controls the [interstitial middleware](#interstitial-challenge) behavior: 944 | 945 | - `key`: Where in the session is stored the challenge reminder. 946 | - `view`: View to use to show the user as the interstitial challenge. 947 | - `route`: The name of the route the middleware should point to. 948 | - `duration`: Default duration to reminder the challenge. The `true` value will remind the challenge forever. An integer will remind the challenge for that amount of minutes. 949 | 950 | ## Testing 951 | 952 | On testing, or when the [environment is `testing`](#environment), the library will automatically fake successful Challenges without contacting Cloudflare Turnstile servers. 953 | 954 | You may create your own fake challenges easily using the `fake()` method of the `Tunrstile` facade. It accepts any of the [Turnstile Challenge attributes](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/#accepted-parameters), which is great to test multiple responses from Turnstile in your application. 955 | 956 | ```php 957 | use Laragear\Turnstile\Facades\Turnstile; 958 | 959 | public function test_comment_is_moderated_when_bot_detected() 960 | { 961 | Turnstile::fake([ 962 | 'success' => false 963 | ]); 964 | 965 | $this->post(['comment' => 'test_comment']); 966 | 967 | $this->assertDatabaseHas('comments', [ 968 | 'body' => 'test_comment', 969 | 'is_moderated' => true 970 | ]) 971 | } 972 | ``` 973 | 974 | #### Testing keys 975 | 976 | This library incorporates the official testing Turnstile Site Keys and Secret Keys as the `Laragear\Turnstile\Enums\SiteKey` and `Laragear\Turnstile\Enums\SecretKey`, respectively. 977 | 978 | The easiest way to change the testing keys on development is to change the environment variables on your `.env` files, as Laravel will automatically restart to pick up the changes. You can use the enum names, as these will be matched automatically to the corresponding testing key. 979 | 980 | ```dotenv 981 | TURNSTILE_SITE_KEY=ForceInteraction 982 | TURNSTILE_SECRET_KEY=Fails 983 | ``` 984 | 985 | For the case of the widget, you may require to refresh your browser so the widget gets re-rendered with the selected key. 986 | 987 | > [!NOTE] 988 | > 989 | > This doesn't work on [production environments](#environment). Ensure you have your correct keys in production! 990 | 991 | Inside your application, you can programmatically swap keys use the `useTestingSiteKey()` and `useTestingSecretKey()` methods of the `Turnstile` facade, along with the corresponding enums for the behaviour you require to check. 992 | 993 | ```php 994 | use Laragear\Turnstile\Enums\SiteKey; 995 | use Laragear\Turnstile\Enums\SecretKey; 996 | use Laragear\Turnstile\Facades\Turnstile; 997 | 998 | Turnstile::useTestingSiteKey(SiteKey::ForceInteraction); 999 | Turnstile::useTestingSecretKey(SecretKey::Fails); 1000 | ``` 1001 | 1002 | For the case of the [widget](#widget), you can change the site key using the `site-key` attribute with the enum case name as value, in either `kebab-case`, `snake_case`, `camelCase` or `StudlyCaps` (these are normalized for you). 1003 | 1004 | ```blade 1005 | 1006 | ``` 1007 | 1008 | ## Laravel Octane compatibility 1009 | 1010 | - There are no singletons using a stale application instance. 1011 | - There are no singletons using a stale config instance. 1012 | - There are no singletons using a stale request instance. 1013 | - There are no static properties written during a request. 1014 | 1015 | There should be no problems using this package with Laravel Octane as intended. 1016 | 1017 | ## HTTP/2 or HTTP/3 and cURL 1018 | 1019 | To use HTTP/3, [ensure you're using PHP 8.2 or later](https://php.watch/articles/php-curl-http3). cURL version [7.66](https://curl.se/changes.html#7_66_0) supports HTTP/3, and latest PHP 8.2 uses version 7.85. 1020 | 1021 | For more information about checking if your platform can make HTTP/3 requests, check this [PHP Watch article](https://php.watch/articles/php-curl-http3). 1022 | 1023 | This library uses HTTP/1.1 by default to ensure backwards compatibility with PHP 8.2. 1024 | 1025 | ## Security 1026 | 1027 | If you discover any security related issues, please [report it using the online form](https://github.com/Laragear/Turnstile/security). 1028 | 1029 | # License 1030 | 1031 | This specific package version is licensed under the terms of the [MIT License](LICENSE.md), at time of publishing. 1032 | 1033 | [Laravel](https://laravel.com) is a Trademark of [Taylor Otwell](https://github.com/TaylorOtwell/). Copyright © 2011-2025 Laravel LLC. 1034 | 1035 | [Cloudflare](https://www.cloudflare.com) and Cloudflare Turnstile are trademarks of [Cloudflare, Inc](https://www.cloudflare.com/trademark/). Copyright © 2009-2025. 1036 | --------------------------------------------------------------------------------