<x-widget.captcha>
Captcha
An image captcha, inline or stacked large above the answer, with an optional audio version and a refresh button. Or Cloudflare Turnstile, reCAPTCHA or hCaptcha, checked on the server with the Captcha rule. In Livewire the image (or the provider's widget) stays put through every render, so the code being answered never changes under you.
php artisan larawell:add captcha
- Also adds
- Icon
Usage
Captcha validation
An image captcha is checked by whatever generated it, e.g. mews/captcha's rule.
'captcha' => ['required', 'captcha'],
// A provider token is checked with the provider. Keys go in config/services.php, read from .env:
// 'turnstile' => ['key' => env('TURNSTILE_SITE_KEY'), 'secret' => env('TURNSTILE_SECRET_KEY')],
use App\Rules\Captcha;
'cf-turnstile-response' => ['required', new Captcha('turnstile')],
// reCAPTCHA: 'g-recaptcha-response', hCaptcha: 'h-captcha-response' (or Captcha::field('hcaptcha')).
Livewire
An image captcha in a Livewire component ($captchaUrl from the component, for example captcha_src() with mews/captcha): bind the answer with wire:model; no name is needed, and its error is read under the property. The image (and the audio) stay put through every render, so the code being answered never changes under you; the refresh button still loads a new one. Validate it as usual in the component: 'captcha' => ['required', 'captcha']. The Turnstile, reCAPTCHA and hCaptcha tokens are written by the provider's own script and aren't bound by wire:model: use those in a regular form.
<form wire:submit="send" class="space-y-5">
<x-widget.captcha label="Type the characters" :src="$captchaUrl" wire:model="captcha" />
<button type="submit">Send</button>
</form>
Examples
Captcha
$captchaUrl is your captcha image's URL from the controller (for example captcha_src() with mews/captcha). The refresh button shows another code.
Pass
$captchaUrl
from your controller.
Show code Hide code
<x-widget.captcha label="Captcha" placeholder="Enter the text" :src="$captchaUrl" class="max-w-sm" />
Captcha stacked
layout="stacked" shows a large, readable image above the answer. audio-src adds a button that plays the code aloud, so people who can't see the image can still pass; refresh reloads both. If the image fails to load, a message asks for another.
Pass
$captchaUrl and
$captchaAudioUrl
from your controller.
Show code Hide code
<x-widget.captcha label="Type the characters you see or hear" layout="stacked" :src="$captchaUrl" :audio-src="$captchaAudioUrl" class="max-w-sm" />
Captcha provider
provider uses Cloudflare Turnstile, Google reCAPTCHA or hCaptcha instead of an image: most people just tick a box, or see nothing. The site key comes from config/services.php; check the token on the server with the Captcha rule (see Usage).
Show code Hide code
<x-widget.captcha provider="turnstile" label="Security check" />
Props
Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.
<x-widget.captcha>
| Prop | Default | Description |
|---|---|---|
| name |
'captcha'
|
What the answer submits as. With a provider, the provider's own token field is used. |
| id |
null
|
Defaults to one made from the name (or the wire:model property). |
| src |
null
|
The image URL (e.g. route('captcha')); refreshing fetches it again, for a new code. |
| audio-src |
null
|
The URL of a spoken version: adds a play button. |
| layout |
'inline'
|
inline (the image beside the field) or stacked (large, above it). |
| provider |
null
|
turnstile, recaptcha or hcaptcha instead of an image; check it with new Captcha($provider). |
| site-key |
null
|
The provider's public site key; defaults to config('services.{provider}.key'). |
| theme |
'auto'
|
The provider widget's theme: auto, light or dark. |
| size |
'normal'
|
The provider widget's size: normal or compact. |
| label |
null
|
Shown above the field, and its name for screen readers. |
| placeholder |
null
|
Shown while it's empty. |
| error |
null
|
An error message of your own; otherwise the validation error for the name, from the session or Livewire. |
| info |
null
|
A hint under the field. |
| bag |
'default'
|
Which error bag to read the error from. |
| disabled |
false
|
Greyed out: it can't be changed. |
Source
What larawell:add captcha writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from
the components it also adds and Field, and the theme and base CSS.
resources/views/components/widget/captcha/index.blade.php Show
@props([
// What the answer submits as. With a provider, the provider's own token field is used.
'name' => 'captcha',
// Defaults to one made from the name (or the wire:model property).
'id' => null,
// The image URL (e.g. route('captcha')); refreshing fetches it again, for a new code.
'src' => null,
// The URL of a spoken version: adds a play button.
'audioSrc' => null,
// inline (the image beside the field) or stacked (large, above it).
'layout' => 'inline',
// turnstile, recaptcha or hcaptcha instead of an image; check it with new Captcha($provider).
'provider' => null,
// The provider's public site key; defaults to config('services.{provider}.key').
'siteKey' => null,
// The provider widget's theme: auto, light or dark.
'theme' => 'auto',
// The provider widget's size: normal or compact.
'size' => 'normal',
// Shown above the field, and its name for screen readers.
'label' => null,
// Shown while it's empty.
'placeholder' => null,
// An error message of your own; otherwise the validation error for the name, from the session or Livewire.
'error' => null,
// A hint under the field.
'info' => null,
// Which error bag to read the error from.
'bag' => 'default',
// Greyed out: it can't be changed.
'disabled' => false,
])
@php
// provider: Cloudflare Turnstile, Google reCAPTCHA or hCaptcha instead of an image. Their widget submits
// its own token field, so errors are looked up under that name. Check it with new Captcha($provider).
$service = $provider !== null ? (\App\Rules\Captcha::PROVIDERS[$provider] ?? throw new \InvalidArgumentException("Unknown captcha provider [{$provider}]. Use turnstile, recaptcha or hcaptcha.")) : null;
$field = \App\View\Widget\FormField::make($service['field'] ?? $name, $id, $errors ?? null, $error, $bag, 'captcha', attributes: $attributes);
// The site key is public, but still comes from config (services.turnstile.key …) rather than the view.
$siteKey ??= $provider !== null ? config("services.{$provider}.key") : null;
$stacked = $layout === 'stacked';
// In a Livewire update the image is left out (data-src only): the browser fetches an <img src> as soon as Livewire
// turns the response into elements, and every fetch is a new code. The one on the page stays (wire:ignore);
// resources/js/widget/captcha loads one that first appears in an update.
$withSrc = ! (class_exists(\Livewire\Livewire::class) && \Livewire\Livewire::isLivewireRequest());
$iconButton = 'text-foreground/60 hover:text-foreground focus-visible:ring-primary grid size-8 shrink-0 place-items-center rounded-full outline-none focus-visible:ring-2 disabled:opacity-50';
@endphp
@if ($service)
<x-widget.field :required="$attributes->has('required')" :id="$field->id" :label="$label" :labels-control="false" :error="$field->errors" :info="$info" :disabled="$disabled" bare :class="$attributes->get('class')">
{{-- The provider's script draws its widget here and adds the hidden token field to the form. Its own id is
the token field's name (reCAPTCHA's textarea is id="g-recaptcha-response"), so this one must differ.
wire:ignore: a Livewire render would empty it again, since the server sends it empty. --}}
<div wire:ignore id="{{ $field->id }}-widget" role="group" @if ($label) aria-labelledby="{{ $field->id }}-label" @endif class="{{ $service['class'] }}" data-sitekey="{{ $siteKey }}" data-theme="{{ $theme }}" data-size="{{ $size }}" data-language="{{ str_replace('_', '-', app()->getLocale()) }}"></div>
@if (! $siteKey)
<p class="text-error mt-1 text-xs">Set services.{{ $provider }}.key in config/services.php to show the {{ $provider }} widget.</p>
@endif
</x-widget.field>
@once
<script src="{{ $service['script'] }}" async defer></script>
@endonce
@else
{{-- An image captcha: `src` must point at a route that generates the image and stores the answer in the session. --}}
<x-widget.field :required="$attributes->has('required')" data-captcha :id="$field->id" :label="$label" :error="$field->errors" :info="$info" :disabled="$disabled" :box="$stacked ? 'h-12 items-center' : 'h-12 items-center pe-3'" :class="$attributes->get('class')">
@if ($stacked)
<x-slot:before>
{{-- Stacked: a large, readable image above the answer, with its controls beside it. --}}
<div class="mb-2 flex items-center gap-2">
{{-- wire:ignore (here, on the small one and on the audio): a Livewire render would put the image's src back,
loading a new code and making the answer being typed wrong. Outside Livewire it does nothing. --}}
<div wire:ignore class="bg-foreground/5 relative h-16 w-full max-w-64 overflow-hidden rounded-xl">
@if ($src)
<img @if ($withSrc) src="{{ $src }}" @endif data-src="{{ $src }}" data-captcha-image alt="Captcha: type the characters shown" draggable="false" class="h-full w-full object-contain select-none">
{{-- Replaces a broken image (resources/js/widget/captcha). --}}
<span data-captcha-error hidden class="text-error absolute inset-0 flex items-center justify-center px-2 text-center text-xs leading-tight">Couldn't load the image. Try another.</span>
@else
<span class="text-muted flex h-full w-full items-center justify-center tracking-widest italic select-none">----</span>
@endif
</div>
@if ($src && $audioSrc)
<button type="button" data-captcha-play aria-label="Play the code aloud" aria-pressed="false" @disabled($disabled) class="{{ $iconButton }}"><x-widget.icon name="volume-2" class="size-5" /></button>
@endif
@if ($src)
<button type="button" data-captcha-refresh aria-label="Show another code" @disabled($disabled) class="{{ $iconButton }} aria-busy:*:animate-spin"><x-widget.icon name="refresh-cw" class="size-5" /></button>
@endif
</div>
</x-slot:before>
@endif
{{-- No old() refill: a failed submit shows a new image, so the previous answer is always wrong. --}}
<input
type="text"
id="{{ $field->id }}"
name="{{ $name }}"
placeholder="{{ $placeholder }}"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
data-captcha-input
@disabled($disabled)
{{ $field->controlAttributes($attributes, (bool) $info)->class([
'h-full w-full min-w-0 bg-transparent ps-5 outline-none placeholder:text-muted disabled:cursor-not-allowed disabled:opacity-50',
'pe-5' => $stacked,
'group-data-invalid/field:placeholder:text-error group-data-invalid/field:focus:placeholder:text-muted',
]) }}
>
@unless ($stacked)
<div wire:ignore class="bg-foreground/5 relative ms-2 h-8 w-25 shrink-0 overflow-hidden rounded-sm">
@if ($src)
<img @if ($withSrc) src="{{ $src }}" @endif data-src="{{ $src }}" data-captcha-image alt="Captcha: type the characters shown" draggable="false" class="h-full w-full object-contain select-none">
{{-- Replaces a broken image (resources/js/widget/captcha). --}}
<span data-captcha-error hidden class="text-error absolute inset-0 flex items-center justify-center px-2 text-center text-xs leading-tight">Couldn't load the image. Try another.</span>
@else
<span class="text-muted flex h-full w-full items-center justify-center tracking-widest italic select-none">----</span>
@endif
</div>
@if ($src && $audioSrc)
<button type="button" data-captcha-play aria-label="Play the code aloud" aria-pressed="false" @disabled($disabled) class="{{ $iconButton }} ms-1"><x-widget.icon name="volume-2" class="size-5" /></button>
@endif
@if ($src)
<button type="button" data-captcha-refresh aria-label="Show another code" @disabled($disabled) class="{{ $iconButton }} ms-1 aria-busy:*:animate-spin"><x-widget.icon name="refresh-cw" class="size-5" /></button>
@endif
@endunless
@if ($src && $audioSrc)
<audio wire:ignore data-captcha-audio data-src="{{ $audioSrc }}" @if ($withSrc) src="{{ $audioSrc }}" @endif preload="none"></audio>
@endif
</x-widget.field>
@endif
resources/js/widget/captcha/index.js Show
// Behaviour for <x-widget.captcha>: reloading the image, the audio and a broken image. Delegated from `document`, so fields added later work without
// re-initialising. Client-side filtering is only for convenience; the Form Request must validate the same rules.
import { on } from '../field';
// --- Captcha: reload the image --------------------------------------------------
on('click', '[data-captcha-refresh]', (event, button) => {
const root = button.closest('[data-captcha]');
const image = root.querySelector('[data-captcha-image]');
const input = root.querySelector('[data-captcha-input]');
// Cache-buster so the browser fetches a fresh image from the same URL.
const url = new URL(image.dataset.src, window.location.href);
url.searchParams.set('_', String(Date.now()));
const done = () => {
button.disabled = false;
button.removeAttribute('aria-busy');
};
button.disabled = true;
button.setAttribute('aria-busy', 'true');
image.addEventListener('load', done, { once: true });
image.addEventListener('error', done, { once: true });
image.src = url.href;
// The spoken version belongs to the same code, so it is reloaded alongside the image.
const audio = root.querySelector('[data-captcha-audio]');
if (audio) {
audio.pause();
const audioUrl = new URL(audio.dataset.src, window.location.href);
audioUrl.searchParams.set('_', url.searchParams.get('_'));
audio.src = audioUrl.href;
}
input.value = '';
input.focus();
});
// A broken image shows a message instead of the browser's broken-image icon, until a refresh loads one.
// load and error don't bubble, so they're caught on the way down; images that failed before this script
// ran are checked once at start.
function showCaptchaImage(image, loaded) {
image.hidden = !loaded;
image.parentElement.querySelector('[data-captcha-error]').hidden = loaded;
}
['load', 'error'].forEach((type) => document.addEventListener(type, (event) => {
if (event.target instanceof HTMLImageElement && event.target.matches('[data-captcha-image]')) {
showCaptchaImage(event.target, type === 'load');
}
}, true));
document.querySelectorAll('img[data-captcha-image]').forEach((image) => {
if (image.complete) {
showCaptchaImage(image, image.naturalWidth > 0);
}
});
// A captcha that first appears in a Livewire update comes without src (see captcha.blade.php): load it now it's here.
function loadCaptchas(scope) {
scope.querySelectorAll('img[data-captcha-image]:not([src]), audio[data-captcha-audio]:not([src])').forEach((media) => {
media.src = media.dataset.src;
});
}
new MutationObserver((records) => {
for (const node of records.flatMap((record) => [...record.addedNodes])) {
if (node instanceof Element) {
loadCaptchas(node.parentElement ?? node);
}
}
}).observe(document.documentElement, { childList: true, subtree: true });
// Audio: play the code aloud, or stop it; the button shows which.
on('click', '[data-captcha-play]', (event, button) => {
const audio = button.closest('[data-captcha]').querySelector('[data-captcha-audio]');
if (audio.paused) {
audio.currentTime = 0;
audio.play().catch(() => window.toast?.error('The audio could not be played.'));
} else {
audio.pause();
}
});
['play', 'pause', 'ended'].forEach((type) => document.addEventListener(type, (event) => {
if (event.target instanceof HTMLAudioElement && event.target.matches('[data-captcha-audio]')) {
event.target.closest('[data-captcha]').querySelector('[data-captcha-play]')?.setAttribute('aria-pressed', String(type === 'play'));
}
}, true));
app/View/Widget/ElementIds.php Show
<?php
declare(strict_types=1);
namespace App\View\Widget;
use Illuminate\Container\Attributes\Scoped;
use LogicException;
/**
* Keeps element ids unique within one response, so labels, aria-describedby and #fragments
* always point at the right element. Scoped: a fresh set per request (and per Octane/queue cycle).
*/
#[Scoped]
final class ElementIds
{
/** @var array<string, true> */
private array $used = [];
/**
* Reserves an id for this response.
*
* A derived id (built from a field name) gets a -2, -3 … suffix when already taken. An explicit
* id is one the caller chose and may reference from JS or CSS, so silently renaming it would
* break that reference; a duplicate throws instead, which surfaces in development and tests.
*/
public function claim(string $id, bool $explicit = false): string
{
if (!isset($this->used[$id])) {
return $this->reserve($id);
}
if ($explicit) {
throw new LogicException("Duplicate element id [{$id}] on this page. Give one of the widgets a different id or name.");
}
$suffix = 2;
while (isset($this->used["{$id}-{$suffix}"])) {
$suffix++;
}
return $this->reserve("{$id}-{$suffix}");
}
private function reserve(string $id): string
{
$this->used[$id] = true;
return $id;
}
}
app/View/Widget/FormField.php Show
<?php
declare(strict_types=1);
namespace App\View\Widget;
use Illuminate\Contracts\Support\MessageBag;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Support\ViewErrorBag;
use Illuminate\View\ComponentAttributeBag;
/**
* Server-side state of one form widget: its dot-notation key, a valid id, its validation
* messages and its old input. Every <x-widget.input.*> and the date picker resolve through
* here, so array names (items[0][date]) and named error bags behave the same everywhere.
*/
final class FormField
{
/**
* @param list<string> $errors
*/
private function __construct(
public readonly ?string $name,
public readonly string $id,
public readonly ?string $key,
public readonly array $errors,
// The property a wire:model or x-model attribute binds it to, if any.
public readonly ?string $bound = null,
) {}
/**
* @param mixed $errorBag the view's shared $errors (absent outside a web request)
* @param string|array<int, string>|null $error an explicit message from the caller; overrides the bag
*/
public static function make(
?string $name,
?string $id,
mixed $errorBag,
string|array|null $error = null,
string $bag = 'default',
string $idPrefix = 'field',
?ComponentAttributeBag $attributes = null,
): self {
// With no name, a Livewire or Alpine binding (wire:model="email") names the field. Its errors are filed under
// that property, and its id stays the same on every render, which Livewire's morph needs to keep the element
// (it matches elements by id: a random one makes it swap in a new field, dropping focus mid-typing).
$bound = $attributes === null ? null : self::boundTo($attributes);
$key = match (true) {
$name !== null && $name !== '' => self::key($name),
$bound !== null => self::key($bound),
default => null,
};
$messages = match (true) {
$error !== null => Arr::wrap($error),
$key !== null && $errorBag instanceof ViewErrorBag => self::messagesFor($errorBag->getBag($bag), $key),
default => [],
};
return new self(
$name,
app(ElementIds::class)->claim(
$id ?? ($key !== null ? self::idFrom($key) : $idPrefix.'-'.Str::random(6)),
explicit: $id !== null,
),
$key,
array_values(array_filter($messages, static fn (mixed $message): bool => is_string($message) && $message !== '')),
$bound,
);
}
/**
* The field's own messages, plus those Laravel files per item for a list of values: a 'tags.*' rule
* reports a bad second choice under tags.1, which a multiple select named tags must still show. Only
* numbered children count, so a field named address doesn't take errors meant for address[city].
*
* @return list<string>
*/
private static function messagesFor(MessageBag $bag, string $key): array
{
$items = array_filter(
$bag->getMessages(),
static fn (string $name): bool => preg_match('/^'.preg_quote($key, '/').'\.\d+$/', $name) === 1,
ARRAY_FILTER_USE_KEY,
);
return array_values(array_unique([...$bag->get($key), ...array_merge(...array_values($items))]));
}
/**
* items[0][date] → items.0.date and tags[] → tags: the key Laravel files errors and old input under.
*/
public static function key(string $name): string
{
return trim((string) preg_replace('/\[([^\]]*)\]/', '.$1', $name), '.');
}
/**
* The id a field named $name gets when it's the first of that name on the page, for links to it
* (the error summary). A later duplicate gets a -2 suffix, which links can't know about.
*/
public static function idFor(string $name): string
{
return self::idFrom(self::key($name));
}
/**
* The property a wire:model or x-model attribute (any modifiers) binds the field to; null without one.
*/
public static function boundTo(ComponentAttributeBag $attributes): ?string
{
return array_values(self::binding($attributes))[0] ?? null;
}
private static function idFrom(string $key): string
{
return trim((string) preg_replace('/[^A-Za-z0-9_-]+/', '-', $key), '-');
}
public function hasError(): bool
{
return $this->errors !== [];
}
public function errorId(): string
{
return $this->id.'-error';
}
public function infoId(): string
{
return $this->id.'-info';
}
/**
* Old input after a failed validation, falling back to the widget's value prop, or with none, to the bound Livewire
* property: a re-render then draws the field as it is, which Livewire morphs onto the page.
*/
public function old(mixed $default = null): mixed
{
if ($default === null) {
[$found, $live] = $this->fromLivewire();
$default = $found ? $live : null;
}
return $this->key === null ? $default : old($this->key, $default);
}
/**
* The bound property's value while Livewire renders the component that holds it: Livewire shares that component
* with every view as $__livewire. Livewire isn't a dependency; it's only looked for. [false, null] otherwise.
* $key reads inside it: a range bound to period reads period.start.
*
* @return array{0: bool, 1: mixed}
*/
public function fromLivewire(?string $key = null): array
{
$component = $this->bound === null ? null : view()->shared('__livewire');
return is_object($component) ? [true, data_get($component, $key === null ? $this->bound : "{$this->bound}.{$key}")] : [false, null];
}
/**
* The binding attribute as written (wire:model.live => period), to put on the inputs that carry the value: a range
* picker binds period.start and period.end with the same modifiers. Empty without one.
*
* @return array<string, string>
*/
public static function binding(ComponentAttributeBag $attributes): array
{
foreach ($attributes->getAttributes() as $attribute => $value) {
if (is_string($value) && $value !== '' && (str_starts_with($attribute, 'wire:model') || str_starts_with($attribute, 'x-model'))) {
return [$attribute => $value];
}
}
return [];
}
/**
* Whether a checkbox or switch renders ticked. An unticked box isn't in the request at all, so after a
* failed submit "no old value" means unticked, but only when that submit was this box's own form. A page
* with a second form (or a disabled box, which is never sent) would otherwise lose every `checked`.
*
* @param bool $alwaysSent it has an unchecked-value, so its form always sends something under its name
* @param mixed $errorBag the view's shared $errors
*/
public function checked(mixed $value, bool $default, bool $disabled, bool $alwaysSent, mixed $errorBag, string $bag = 'default'): bool
{
// Bound to a Livewire property: that says, true/false, or for a list of boxes, whether it holds this value.
[$found, $live] = $this->fromLivewire();
if ($found) {
return is_array($live) ? in_array(self::text($value), array_map(self::text(...), $live), true) : (bool) $live;
}
if ($this->key === null || $disabled || !session()->hasOldInput()) {
return $default;
}
$old = old($this->key);
if ($old !== null) {
return in_array(self::text($value), array_map(self::text(...), Arr::wrap($old)), true);
}
// Nothing under this name, though this box always sends something: its form wasn't the one submitted.
if ($alwaysSent) {
return $default;
}
// A form with its own error bag: no errors in that bag means the failed submit was a different form.
if ($bag !== 'default') {
return $errorBag instanceof ViewErrorBag && $errorBag->getBag($bag)->isNotEmpty() ? false : $default;
}
// One form, or forms sharing the default bag: there's no telling them apart, so trust the old input.
return false;
}
/** Values cast to backed enums (Plan::Pro) compare as their backing value. */
private static function text(mixed $value): string
{
return (string) ($value instanceof \BackedEnum ? $value->value : $value);
}
/**
* What the caller passed, minus `class` (that styles the wrapper) and the aria attributes
* this widget manages itself. Goes on the element that is actually submitted.
*/
public function forwarded(ComponentAttributeBag $attributes): ComponentAttributeBag
{
return $attributes->except(['class', 'aria-invalid', 'aria-describedby']);
}
/**
* aria-invalid plus one aria-describedby that joins the error, the hint and the caller's own
* ids. Two separate aria-describedby attributes would make the browser silently drop one.
*/
public function aria(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
{
$describedBy = array_filter([
$this->hasError() ? $this->errorId() : null,
$hasInfo ? $this->infoId() : null,
$attributes->get('aria-describedby'),
]);
return new ComponentAttributeBag([
'aria-invalid' => $this->hasError() ? 'true' : null,
'aria-describedby' => $describedBy === [] ? null : implode(' ', $describedBy),
]);
}
/**
* For a widget whose visible control isn't the submitted input (grouped number, phone): what belongs on the
* hidden input that carries the value. Which form it's in, and Livewire and Alpine bindings, go with the value.
*/
public function bindings(ComponentAttributeBag $attributes): ComponentAttributeBag
{
return $attributes->filter(static fn (mixed $value, string $key): bool => self::isBinding($key));
}
/**
* The other side of bindings(): everything else the caller passed (required, autofocus, aria-label,
* placeholder…) goes on the visible control, where the browser validates it and screen readers hear it.
*/
public function visibleAttributes(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
{
return $this->controlAttributes($attributes->filter(static fn (mixed $value, string $key): bool => !self::isBinding($key)), $hasInfo);
}
private static function isBinding(string $key): bool
{
return $key === 'form' || str_starts_with($key, 'wire:model') || str_starts_with($key, 'x-model');
}
/**
* forwarded() and aria() together, for widgets whose visible control is also the submitted one.
*/
public function controlAttributes(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
{
return $this->forwarded($attributes)->merge($this->aria($attributes, $hasInfo)->getAttributes());
}
}
app/Rules/Captcha.php Show
<?php
declare(strict_types=1);
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use InvalidArgumentException;
/**
* Checks a Turnstile, reCAPTCHA or hCaptcha token with the provider, for <x-widget.captcha provider="…">.
* Fails closed: a missing secret, an unreachable provider or an unexpected answer all count as a failed check.
*
* 'cf-turnstile-response' => ['required', new Captcha('turnstile')],
*
* Keys live in config/services.php (services.turnstile.key / .secret, and so on), never in code.
*/
final class Captcha implements ValidationRule
{
/**
* What each provider needs on the page and on the server.
*
* @var array<string, array{script: string, class: string, field: string, verify: string}>
*/
public const array PROVIDERS = [
'turnstile' => [
'script' => 'https://challenges.cloudflare.com/turnstile/v0/api.js',
'class' => 'cf-turnstile',
'field' => 'cf-turnstile-response',
'verify' => 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
],
'recaptcha' => [
'script' => 'https://www.google.com/recaptcha/api.js',
'class' => 'g-recaptcha',
'field' => 'g-recaptcha-response',
'verify' => 'https://www.google.com/recaptcha/api/siteverify',
],
'hcaptcha' => [
'script' => 'https://js.hcaptcha.com/1/api.js',
'class' => 'h-captcha',
'field' => 'h-captcha-response',
'verify' => 'https://api.hcaptcha.com/siteverify',
],
];
public function __construct(private readonly string $provider)
{
if (!isset(self::PROVIDERS[$provider])) {
throw new InvalidArgumentException("Unknown captcha provider [{$provider}]. Use turnstile, recaptcha or hcaptcha.");
}
}
/** The field the provider's widget submits its token in, e.g. cf-turnstile-response. */
public static function field(string $provider): string
{
return self::PROVIDERS[$provider]['field'] ?? throw new InvalidArgumentException("Unknown captcha provider [{$provider}].");
}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$secret = config("services.{$this->provider}.secret");
if (!is_string($value) || $value === '' || !is_string($secret) || $secret === '') {
$fail('Please confirm you are not a robot.');
return;
}
try {
$response = Http::asForm()->timeout(5)->post(self::PROVIDERS[$this->provider]['verify'], [
'secret' => $secret,
'response' => $value,
'remoteip' => request()->ip(),
]);
} catch (ConnectionException) {
$fail('We could not check that you are not a robot. Please try again.');
return;
}
if ($response->json('success') !== true) {
$fail('Please confirm you are not a robot.');
}
}
}