oliweb / laravel-cap
Laravel wrapper for Cap (tiagozip/cap) — self-hosted CAPTCHA alternative
Requires
- php: ^8.2
- illuminate/cache: ^12.0|^13.0
- illuminate/console: ^12.0|^13.0
- illuminate/events: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/notifications: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.0|^12.0
Suggests
- livewire/livewire: Livewire 4 integration: <x-cap::livewire> component and WithCap trait (^4.0)
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-09 11:18:09 UTC
README
A Laravel wrapper for Cap — the self-hosted, privacy-friendly CAPTCHA alternative based on Proof-of-Work.
Cap works without tracking, cookies, or third-party services. This package integrates server-side token verification into Laravel through a service, a facade, a middleware, a validation rule, and Blade directives.
Requirements
- PHP ^8.2
- Laravel 12 or 13
- A running Cap instance (self-hosted via Docker) — capd v4 required
Server version compatibility
The PHP verification layer (
Cap::verify, middleware, rule) calls/siteverifyand is compatible with any Cap server version.The bundled widget (
cap-widget.js@0.1.61) targets capd v4. It sends behavior scoring data introduced in that version. Using it against a Cap v3 server with instrumentation enabled will likely cause[instr_timeout]errors. Basic PoW without instrumentation may still work, but this combination is untested and unsupported.
Installation
composer require oliweb/laravel-cap
The service provider and facade are registered automatically via Laravel's package auto-discovery.
Publish the configuration file:
php artisan vendor:publish --tag=cap-config
Publish the JS, CSS, and WASM assets (required for @capScripts and @capStyles):
php artisan vendor:publish --tag=cap-assets
This publishes the following files to public/vendor/cap/:
| File | Description |
|---|---|
cap-widget.js |
Cap widget (custom element + programmatic API) |
cap-widget.css |
Default widget styles |
cap_wasm_bg.wasm |
WebAssembly binary for rsw proof-of-work |
cap_wasm.js |
JS loader for the WASM module |
hashwx.wasm |
WebAssembly binary for hashwx proof-of-work (GPU-resistant, default since Cap 3.1.12) |
Upgrading assets
After composer update oliweb/laravel-cap, re-publish assets with --force to overwrite the files in public/vendor/cap/ with the versions bundled in the new package:
php artisan vendor:publish --tag=cap-assets --force
Upgrading the Cap server to capd v4
capd@4.0.0 (formerly "Cap Standalone") requires a manual secret reset for each site key.
Until you do so, /siteverify returns HTTP 403 and every token verification will fail.
Navigate to Cap dashboard → your site key → Settings → Danger zone → Reset secret, then update CAP_SECRET in your .env.
laravel-caphandles 403 responses as a configuration error: theCapMisconfiguredevent is dispatched and, ifCAP_ALERT_MAILis set, an alert e-mail is sent.
Publish the translation files (optional — to override messages):
php artisan vendor:publish --tag=cap-lang
Configuration
Add the following variables to your .env file:
CAP_ENDPOINT=https://cap.example.com/your-site-key/ CAP_SECRET=your-secret-key CAP_TOKEN_FIELD=cap-token CAP_TIMEOUT=5 CAP_FAIL_OPEN=false
| Variable | Description | Default |
|---|---|---|
CAP_ENDPOINT |
Full URL of your Cap instance including the site key (trailing slash required) | — |
CAP_SECRET |
Secret key from your Cap dashboard | — |
CAP_TOKEN_FIELD |
Name of the hidden field injected by the Cap widget | cap-token |
CAP_TIMEOUT |
HTTP timeout in seconds for the /siteverify request |
5 |
CAP_FAIL_OPEN |
When true, let requests through on network/server errors (see below) |
false |
CAP_FRAME_ROUTE |
URL path for the iframe route (used by @capFrame) |
cap-frame |
Fail-open mode
By default, any communication error with the Cap instance (network failure, timeout, HTTP 5xx) blocks the request, just like an invalid token would.
Setting CAP_FAIL_OPEN=true inverts this: communication errors silently pass, so a Cap outage does not take your forms down with it.
An explicitly invalid token (success: false) is always rejected regardless of this setting. Fail-open only covers infrastructure failures (network, timeout, HTTP 5xx), not verification failures. HTTP 4xx responses (replayed token, wrong secret, misconfigured endpoint) are also always rejected regardless of this setting.
Translations
Server-side messages (validation rule, middleware) are translatable. English and French are included out of the box.
To override or add a language, publish the translation files and edit lang/vendor/cap/{locale}/messages.php:
php artisan vendor:publish --tag=cap-lang
// lang/vendor/cap/fr/messages.php return [ 'validation_failed' => 'La vérification :attribute a échoué. Veuillez réessayer.', 'middleware_failed' => 'La vérification Cap a échoué.', ];
Laravel selects the right file automatically based on App::getLocale().
Alerts & Monitoring
When the Cap server becomes unreachable or is misconfigured, laravel-cap dispatches
Laravel events that you can listen to in your application. An optional built-in listener
sends you an e-mail alert automatically.
Events
CapServerUnreachable
Dispatched when the Cap server cannot be reached:
- Connection refused, timeout, DNS failure (caught
\Throwable) - HTTP 5xx response from
/siteverify
| Property | Type | Description |
|---|---|---|
$endpoint |
string |
The /siteverify URL that was called (no secret) |
$failOpen |
bool |
Whether fail-open is active (CAPTCHA inactive when true) |
$exceptionMessage |
string |
Error message or HTTP status (e.g. HTTP 503) |
$occurredAt |
\DateTimeImmutable |
Timestamp of the failure |
CapMisconfigured
Dispatched when /siteverify returns an HTTP 4xx response, indicating a configuration
problem (wrong secret, wrong site key, wrong endpoint):
| Property | Type | Description |
|---|---|---|
$endpoint |
string |
The URL that was called (no secret) |
$statusCode |
int |
HTTP status code (401, 403, 404, ...) |
Never triggers an alert: a token explicitly refused by the server (
success: false) is normal operation and dispatches no event.
Built-in mail listener
Activate by setting CAP_ALERT_MAIL in your .env:
CAP_ALERT_MAIL=admin@example.com CAP_ALERT_THROTTLE_MINUTES=15 # optional, default 15
The listener (SendCapAlertMail):
- Implements
ShouldQueue-- processed by your queue worker - Sends one notification per event type per throttle window (default 15 min)
- Mail is sent via
Notification::route-- noUsermodel required - Mentions whether fail-open is active (i.e. whether CAPTCHA protection is currently off)
- Available in English and French (follows your application locale)
The listener is only registered when cap.alerts.mail is configured. If the key is
absent or null, no listener is registered and no overhead is incurred.
Custom listeners
Listen to these events in your AppServiceProvider or EventServiceProvider:
use LaravelCap\Events\CapServerUnreachable; use LaravelCap\Events\CapMisconfigured; // In boot(): Event::listen(CapServerUnreachable::class, function ($event) { // Slack, PagerDuty, custom log... Log::critical("Cap server unreachable", [ "endpoint" => $event->endpoint, "fail_open" => $event->failOpen, "error" => $event->exceptionMessage, ]); }); Event::listen(CapMisconfigured::class, function ($event) { Log::error("Cap misconfigured", [ "endpoint" => $event->endpoint, "http" => $event->statusCode, ]); });
cap:check command
Probes the Cap server health endpoint (GET /health) and exits with a meaningful code:
| Exit code | Meaning |
|---|---|
0 |
Server healthy ({"status":"ok"}) |
1 |
Server unreachable (connection error, timeout) |
2 |
Server reachable but unhealthy ({"status":"unavailable"}) |
php artisan cap:check
With --notify, dispatches the same events as a live request failure (useful for testing
your alert pipeline):
php artisan cap:check --notify
Scheduler integration -- add to routes/console.php:
use Illuminate\Support\Facades\Schedule; Schedule::command("cap:check --notify") ->everyFiveMinutes() ->withoutOverlapping() ->emailOutputOnFailure("admin@example.com"); // optional fallback
Configuration reference
// config/cap.php 'alerts' => [ 'mail' => env('CAP_ALERT_MAIL'), // null = disabled 'throttle_minutes' => (int) env('CAP_ALERT_THROTTLE_MINUTES', 15), ],
Widget styling
Edit public/vendor/cap/cap-widget.css to override the CSS custom properties exposed by the widget:
cap-widget { --cap-color-primary: #6366f1; --cap-color-success: #22c55e; --cap-border-radius: 0.5rem; --cap-font-family: inherit; /* ... */ }
Usage
Blade directives
| Directive | Description |
|---|---|
@cap |
Renders <cap-widget> with the configured endpoint |
@capScripts |
Injects window.CAP_CUSTOM_WASM_URL, window.CAP_CUSTOM_HASHWX_URL + <script type="module"> for the widget |
@capStyles |
<link> loading the theme from public/vendor/cap/cap-widget.css |
@capConfig |
<script> exposing window.CAP_API_ENDPOINT and window.CAP_TOKEN_FIELD |
@capFrame |
Renders the Cap widget in an isolated iframe with a permissive CSP — the parent page keeps a strict CSP without 'unsafe-eval' |
Standard widget mode
Include the Cap widget in any Blade form:
@capStyles @capScripts <form method="POST" action="/contact"> @csrf @cap <button type="submit">Submit</button> </form>
The widget automatically injects a hidden cap-token field (or the value of CAP_TOKEN_FIELD) into its parent form upon successful verification.
@capScripts always injects window.CAP_CUSTOM_WASM_URL and window.CAP_CUSTOM_HASHWX_URL pointing to the locally published WASM files, so no external CDN is contacted at runtime.
Programmatic mode
Use @capConfig to expose the endpoint to JavaScript, then instantiate Cap directly without rendering a visible widget:
@capConfig @capScripts <form method="POST" action="/contact"> @csrf <input type="hidden" name="cap-token" id="cap-token"> <button type="submit" id="submit-btn">Submit</button> </form> <script type="module"> document.getElementById('submit-btn').addEventListener('click', async (e) => { e.preventDefault(); const cap = new Cap({ apiEndpoint: window.CAP_API_ENDPOINT }); const { token } = await cap.solve(); document.getElementById('cap-token').value = token; e.target.closest('form').submit(); }); </script>
Cap creates a hidden cap-widget element in the background and exposes a solve() method that returns { token }. No visible widget is rendered.
window.CAP_API_ENDPOINT and window.CAP_TOKEN_FIELD are set by @capConfig from your PHP configuration, so you never need to hard-code the endpoint in JavaScript.
Iframe mode (strict CSP — no 'unsafe-eval')
When Cap's instrumentation is enabled, the widget requires 'unsafe-eval' in script-src. If your page enforces a strict CSP, use @capFrame instead: it serves the widget in a dedicated iframe (/cap-frame) with its own permissive CSP, keeping the parent page clean.
{{-- In your layout: no @capScripts or @capStyles needed --}} <form @submit.prevent="submitWithCap"> @csrf @capFrame <button type="submit">Submit</button> </form>
This renders:
<input type="hidden" name="cap-token" id="cap-frame-token"> <iframe src="/cap-frame" id="cap-frame" style="width:0;height:0;border:0;overflow:hidden;" title="Cap CAPTCHA" aria-hidden="true"></iframe> <script> (function(){ window.addEventListener('message', function(e) { if (e.origin !== window.location.origin) return; var _f = document.getElementById('cap-frame'); if (!_f || e.source !== _f.contentWindow) return; if (!e.data || e.data.type !== 'cap:token') return; document.getElementById('cap-frame-token').value = e.data.token; }); window.capSolve = function() { document.getElementById('cap-frame').contentWindow .postMessage({type: 'cap:start'}, window.location.origin); }; })(); </script>
/cap-frame route is registered automatically by the service provider. Its Content-Security-Policy header includes 'unsafe-eval', 'wasm-unsafe-eval', blob:, and img-src data: — everything Cap needs — while frame-ancestors 'self' prevents embedding from external origins. window.CAP_ISOLATE = true is set inside the frame so the instrumentation sub-iframe runs in a fully sandboxed opaque origin.
Token flow:
Parent (strict CSP) iframe /cap-frame (permissive CSP)
│── postMessage(cap:start) ──►│ widget.solve()
│◄── postMessage(cap:token) ──│ e.detail.token
│ fills #cap-frame-token │
Programmatic trigger — @capFrame exposes window.capSolve() on the parent page:
// Trigger Cap resolution from Alpine, Vue, React, etc. window.capSolve(); // Listen for the token (in addition to the hidden input auto-fill) window.addEventListener('message', (e) => { if (e.origin !== window.location.origin) return; if (!e.data || e.data.type !== 'cap:token') return; // e.data.token is ready — pass it to your backend myForm.submit(e.data.token); });
With nonce (if your CSP uses nonces):
@capFrame(Vite::cspNonce())
Customising the route path — set CAP_FRAME_ROUTE in .env:
CAP_FRAME_ROUTE=captcha/frame
Multiple @capFrame instances
When a page hosts several forms, pass a unique id as the second argument:
The custom id must satisfy these constraints:
- pattern:
^[A-Za-z][A-Za-z0-9_-]*$(starts with a letter; only letters, digits, hyphens, underscores) - maximum length: 64 characters
Passing an invalid id throws an InvalidArgumentException immediately at template render time.
{{-- Login form --}} @capFrame(Vite::cspNonce(), 'login-cap') {{-- without nonce: @capFrame(null, 'login-cap') --}} {{-- Contact form --}} @capFrame(Vite::cspNonce(), 'contact-cap')
Each instance gets its own namespaced trigger function instead of window.capSolve:
| Directive | iframe / input ids | trigger |
|---|---|---|
@capFrame |
cap-frame / cap-frame-token |
window.capSolve() |
@capFrame($nonce) |
cap-frame / cap-frame-token |
window.capSolve() |
@capFrame(null, 'login-cap') |
login-cap / login-cap-token |
window['capSolve_login-cap']() |
@capFrame($nonce, 'login-cap') |
login-cap / login-cap-token |
window['capSolve_login-cap']() |
// Trigger Cap in the login form window['capSolve_login-cap'](); // Trigger Cap in the contact form window['capSolve_contact-cap']();
The hidden <input> is filled automatically when Cap resolves. @capFrame with the default id remains fully backward-compatible.
CSP nonce support
All directives accept an optional nonce for strict Content Security Policies:
@capConfig(Vite::cspNonce()) @capScripts(Vite::cspNonce()) @cap(Vite::cspNonce())
@cap passes the nonce as data-cap-csp-nonce on the widget element, which Cap uses internally for its workers and inline scripts.
CSP headers
Cap's widget runs WebAssembly locally. A strict CSP must account for this:
Content-Security-Policy:
script-src 'nonce-{nonce}' 'strict-dynamic' 'wasm-unsafe-eval';
connect-src 'self' https://your-cap-instance.example.com;
'wasm-unsafe-eval' — required for the WebAssembly proof-of-work computation.
connect-src — must include your Cap instance origin so the widget can reach /challenge and /redeem.
Instrumentation and strict CSP
Cap's optional instrumentation feature (enabled per site key in the Cap admin dashboard) runs fingerprinting code inside a sandboxed iframe. This code calls
eval()andnew Function(), which are blocked by ascript-srcwithout'unsafe-eval'.If instrumentation is enabled and
'unsafe-eval'is absent from your CSP, the widget will report an[instr_timeout]error and the Cap server will return HTTP 429, making every verification attempt fail.Recommended workaround: use
@capFrame— the widget runs in a dedicated iframe with its own permissive CSP, leaving the parent page CSP strict.CAP_ISOLATE = trueis set automatically inside the iframe for an extra sandboxing layer.Alternative workarounds:
- Set
window.CAP_ISOLATE = truebefore@capScriptsin direct mode — the instrumentation then runs in an opaque-origin sandboxed iframe, removing the need for'unsafe-eval'in the parent CSP ('wasm-unsafe-eval'is still required for the proof-of-work).- Disable instrumentation for the site key in the Cap admin dashboard.
- Add
'unsafe-eval'toscript-src(weakens the CSP of the parent page).
Middleware
Protect any route by applying the cap.verify middleware:
Route::post('/contact', [ContactController::class, 'store']) ->middleware('cap.verify');
Returns HTTP 422 with the message Cap verification failed. if the token is missing or invalid.
Validation rule
Use CapRule inside a Form Request or an inline validator:
use LaravelCap\Rules\CapRule; public function rules(): array { return [ 'cap-token' => ['required', new CapRule], // other fields... ]; }
Facade
use LaravelCap\Facades\Cap; if (Cap::verify($request->input('cap-token'))) { // token is valid }
Service (dependency injection)
use LaravelCap\Cap; class ContactController extends Controller { public function __construct(private readonly Cap $cap) {} public function store(Request $request): RedirectResponse { $this->cap->verifyOrFail($request->input('cap-token')); // ... } }
verifyOrFail() throws a CapVerificationException if the token is invalid.
Livewire integration
Requirement:
livewire/livewire ^4.0. Livewire is an optional dependency — the package works fully without it.
Installation
composer require livewire/livewire
The <x-cap::livewire> component and WithCap trait are registered automatically by
CapServiceProvider when Livewire is detected.
Component <x-cap::livewire>
Drop the component in any Livewire template and bind it to a string property via
wire:model:
<x-cap::livewire wire:model="capToken" />
With a CSP nonce:
<x-cap::livewire wire:model="capToken" :nonce="$nonce" />
The component renders the <cap-widget> inside a wire:ignore container (survives
Livewire's DOM morphing). The resolved token is synced to the bound Livewire property
via Alpine + $wire.set() without triggering an intermediate network request. The
widget automatically resets when the cap-reset browser event is dispatched.
WithCap trait
Add WithCap to your Livewire component to get server-side Cap verification and
automatic widget reset on failure:
use Livewire\Component; use LaravelCap\Livewire\WithCap; class ContactForm extends Component { use WithCap; public string $capToken = ''; public string $name = ''; public string $email = ''; public function submit(): void { // Validate other fields first — avoids consuming the Cap token // if a simple required/email check fails $this->validate([ 'name' => 'required|string|max:255', 'email' => 'required|email', ]); // Verify the Cap token server-side (single-use, cleared on success) $this->validateCap(); // Process the form... $this->dispatch('form-submitted'); } public function render() { return view('livewire.contact-form'); } }
{{-- resources/views/livewire/contact-form.blade.php --}} <form wire:submit="submit"> <div> <input type="text" wire:model="name" placeholder="Name" /> </div> <div> <input type="email" wire:model="email" placeholder="Email" /> </div> <x-cap::livewire wire:model="capToken" /> <button type="submit">Send</button> </form>
Available methods
| Method | Description |
|---|---|
validateCap(string $property = 'capToken') |
Verifies the token via CapRule. Throws ValidationException if invalid. Clears the token on success. |
resetCap(string $property = 'capToken') |
Clears the token server-side and dispatches cap-reset to reset the widget. |
Automatic reset on failure
The trait's exception() Livewire lifecycle hook automatically calls resetCap() on
any ValidationException, even if the error comes from a different field. The widget is
always reset after a failed submission, forcing the user to solve the CAPTCHA again.
Validation order: call
validateCap()after$this->validate()for other fields. This way, ifnameorexception()hook but was never consumed by the Cap server. The user solves the CAPTCHA only once per successful submission.
Security note
capTokenis a public property (required forwire:model). Its value is always verified server-side against the Cap API on every submission — a client-side modification cannot bypass verification. Never store a boolean "captcha solved" state in an unprotected public property.
Inertia integration
No package-side changes are needed for Inertia: CapRule and the cap.verify
middleware apply to Inertia requests exactly like regular form requests.
Vue 3 setup
Declare cap-widget as a custom element in your Vite config to prevent Vue from
warning about an unknown component:
// vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [ vue({ template: { compilerOptions: { isCustomElement: (tag) => tag === 'cap-widget', }, }, }), ], })
Pass config('cap.endpoint') to the front end via your Inertia shared data or as a
page prop:
// In your controller: return Inertia::render('ContactForm', [ 'capEndpoint' => config('cap.endpoint'), ]);
Vue 3 example (Inertia.js v2, @inertiajs/vue3):
<script setup> import { useForm } from '@inertiajs/vue3' import { ref } from 'vue' const props = defineProps({ capEndpoint: String }) const form = useForm({ name: '', email: '', 'cap-token': '', }) const capWidget = ref(null) function onCapSolve(event) { form['cap-token'] = event.detail.token } function submit() { form.post(route('contact.store'), { onError() { // Always reset — the token is single-use form['cap-token'] = ''; capWidget.value?.reset() }, }) } </script> <template> <form @submit.prevent="submit"> <input v-model="form.name" type="text" placeholder="Name" /> <input v-model="form.email" type="email" placeholder="Email" /> <cap-widget ref="capWidget" :data-cap-api-endpoint="props.capEndpoint" @solve="onCapSolve" /> <button type="submit" :disabled="form.processing">Send</button> </form> </template>
React 19 example (Inertia.js v2, @inertiajs/react):
import { useForm } from '@inertiajs/react' import { useRef, useEffect } from 'react' export default function ContactForm({ capEndpoint }) { const capRef = useRef(null) const { data, setData, post, processing } = useForm({ name: '', email: '', 'cap-token': '', }) useEffect(() => { const widget = capRef.current if (!widget) return const handleSolve = (e) => setData('cap-token', e.detail.token) widget.addEventListener('solve', handleSolve) return () => widget.removeEventListener('solve', handleSolve) }, []) function submit(e) { e.preventDefault() post(route('contact.store'), { onError() { // Always reset — the token is single-use setData('cap-token', '') capRef.current?.reset() }, }) } return ( <form onSubmit={submit}> <input value={data.name} onChange={e => setData('name', e.target.value)} placeholder="Name" /> <input value={data.email} onChange={e => setData('email', e.target.value)} placeholder="Email" /> <cap-widget ref={capRef} data-cap-api-endpoint={capEndpoint} /> <button type="submit" disabled={processing}>Send</button> </form> ) }
Note on React events: React 19 does not forward custom DOM events (
solve,reset) through JSX props. UseaddEventListenervia auseEffect+ ref as shown above instead ofonSolve={…}.
Single-use token — Inertia gotcha
Inertia intercepts HTTP 422 responses and surfaces field errors without a full page reload. The Cap token submitted with the form has already been consumed by the server — even if the error is on a completely different field (
name,You must always call
widget.reset()and clear the token field inonError, or the next submission will fail with an invalid-token error regardless of whether the CAPTCHA was solved correctly.
Testing
composer install ./vendor/bin/phpunit
License
MIT