oliweb / statamic-cap
Statamic addon for Cap CAPTCHA (proof-of-work) integration
Requires
- php: ^8.2
- oliweb/laravel-cap: ^1.0
- statamic/cms: ^5.0|^6.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpunit/phpunit: ^11.0|^12.0
README
Statamic addon to integrate Cap — a self-hosted proof-of-work CAPTCHA — into Statamic forms.
Built on top of oliweb/laravel-cap.
Requirements
- PHP 8.2+
- Statamic 5.x or 6.x
- A self-hosted Cap instance
Installation
composer require oliweb/statamic-cap
Assets (JS + CSS) are served automatically by the addon via dedicated routes. No vendor:publish required.
Configuration
Via the Statamic CP
Go to Tools > Cap CAPTCHA in the Statamic control panel.
| Field | Description |
|---|---|
| Cap Endpoint URL | Full URL of your Cap instance, including the site key (e.g. https://cap.example.com/your-site-key/) |
| Token Field Name | Name of the hidden field injected by the widget (default: cap-token) |
| Timeout (seconds) | Request timeout for /siteverify calls (default: 5) |
| Fail Open | If enabled, allows requests through on Cap communication errors |
| Hide Attribution Link | If enabled, hides the "Cap" attribution link at the bottom right of the widget |
| Allow CDN fallback for WASM | If enabled, falls back to cdn.jsdelivr.net when the local WASM has not been published. Disabled by default — see note below. |
Breaking change (v1.8+) — WASM CDN fallback: prior to this version, a missing local WASM file silently redirected to
cdn.jsdelivr.net. This fallback is now opt-in: withAllow CDN fallback for WASMdisabled (the new default), a missing local WASM returns a 503 instead. The recommended solution is to publish the WASM locally once viaphp artisan cap:publish-wasm. Enable the CDN fallback only if you explicitly accept the external dependency.
Important — Cap secret: the secret is not configurable from this panel. It must be set exclusively via the
CAP_SECRETenvironment variable in.env(or via the publishedoliweb/laravel-capconfiguration). Exposing the secret in the CP panel is a security risk; it is read directly fromconfig('cap.secret')at verification time.
Config key resolution:
endpoint,token_field,timeout, andfail_openare read fromconfig('statamic-cap.*'), withconfig('cap.*')as a fallback for values not overridden in the CP.secretis sourced exclusively fromconfig('cap.secret')and is never read from thestatamic-capnamespace. If a verification call uses the wrong endpoint after a CP change, check that the YAML file has been written tostorage/statamic/addons/statamic-cap.yamland that the config cache has been cleared.
Settings are saved to storage/statamic/addons/statamic-cap.yaml.
Via environment variables
Environment variables serve as default values and are overridden by CP settings for editable fields.
# Configurable from the CP panel as well CAP_ENDPOINT=https://cap.example.com/your-site-key/ CAP_TOKEN_FIELD=cap-token CAP_TIMEOUT=5 CAP_FAIL_OPEN=false CAP_HIDE_ATTRIBUTION=false CAP_WASM_CDN_FALLBACK=false # Secret — via .env or config/cap.php only (never from the CP panel) CAP_SECRET=your-secret-key
Usage
Antlers tags
| Tag | Description |
|---|---|
{{ cap }} |
Renders the <cap-widget> with the configured endpoint |
{{ cap:scripts }} |
Injects window.CAP_CUSTOM_WASM_URL + <script type="module"> for the widget |
{{ cap:styles }} |
Widget CSS <link> tag |
{{ cap:config }} |
<script> exposing window.CAP_API_ENDPOINT and window.CAP_TOKEN_FIELD |
Standard widget mode
Load assets in the layout and add the widget to a Statamic form:
<head> {{ cap:styles }} </head> <body> {{ form:create handle="contact" }} {{ cap }} <button type="submit">Send</button> {{ /form:create }} {{ cap:scripts }} </body>
The widget automatically injects a hidden cap-token field into the parent form upon verification.
{{ cap:scripts }} always injects window.CAP_CUSTOM_WASM_URL pointing to the local WASM route — no external request at runtime.
Programmatic mode
Use {{ cap:config }} to expose the endpoint in JavaScript, then instantiate Cap directly without rendering a visible widget:
<head> {{ cap:styles }} </head> <body> {{ cap:config }} {{ cap:scripts }} <form method="POST" action="/contact"> <input type="hidden" name="cap-token" id="cap-token"> <button type="submit" id="submit-btn">Send</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> </body>
Cap automatically creates a hidden cap-widget element in the background. No visible widget is rendered.
window.CAP_API_ENDPOINT and window.CAP_TOKEN_FIELD are set by {{ cap:config }} from the PHP configuration — no JavaScript hard-coding required.
With CSP nonce
{{ cap:config nonce="{ $cspNonce }" }}
{{ cap:scripts nonce="{ $cspNonce }" }}
{{ cap nonce="{ $cspNonce }" }}
CSP headers
The widget relies on Web Workers and WebAssembly. A strict CSP must include:
Content-Security-Policy:
script-src 'nonce-{nonce}' 'strict-dynamic';
worker-src blob:;
wasm-unsafe-eval;
connect-src 'self';
worker-src blob: — required because the widget spawns workers via Blob URLs.
wasm-unsafe-eval — required for WebAssembly hash computation.
connect-src 'self' — sufficient when WASM is served locally (see below).
Automatic validation
Token verification is automatic: the addon listens to Statamic's FormSubmitted event and rejects the submission if the token is invalid or missing. No additional configuration required.
On failure, a validation error is returned with the message statamic-cap::messages.validation_failed.
Disabling Cap on a specific form
By default, every form submission is verified. To exclude a specific form (e.g. an internal admin form that never renders the Cap widget), use the Cap tab in Statamic's form editor in the control panel and toggle Disable Cap on.
Alternatively, add cap_disabled: true directly as a top-level key in the form's YAML file (useful for Git-managed form configurations):
# resources/forms/my_internal_form.yaml title: My Internal Form cap_disabled: true fields: - handle: name field: type: text
When cap_disabled: true is present, the listener exits immediately without making any network request to Cap's /siteverify endpoint.
Non-breaking: forms that do not have
cap_disabledin their YAML are protected exactly as before. The absence of the key is treated ascap_disabled: false.
Local WASM (strict CSP)
By default, {{ cap:scripts }} injects window.CAP_CUSTOM_WASM_URL pointing to the /vendor/statamic-cap/cap_wasm_bg.wasm route. As of v1.8.0, this route returns a 503 if the local WASM has not been published — CDN fallback is opt-in and disabled by default (see the Breaking change note above).
For fully self-hosted operation with no external requests, download the WASM file locally:
php artisan cap:publish-wasm
The file is saved to storage/app/statamic-cap/cap_wasm_bg.wasm and served automatically. The CSP can then be limited to connect-src 'self' without whitelisting jsDelivr.
Translations
Translations are available in English, French, and German. Strings cover validation, error messages, widget labels, and the CP settings page.
To customise them, copy and edit the file in your project:
lang/vendor/statamic-cap/{locale}/messages.php
License
MIT — see LICENSE