unbotable / unbotable-laravel
Laravel integration for Unbotable — privacy-respecting bot and spam protection
Requires
- php: ^8.2
- ext-sodium: *
- illuminate/http: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^2.9|^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.0|^12.0
This package is not auto-updated.
Last update: 2026-08-25 03:33:40 UTC
README
Privacy-respecting bot and spam protection for Laravel forms and logins — no CAPTCHAs for real visitors, no tracking, no third-party data harvesting.
This package is the Laravel client for Unbotable. It talks to the Unbotable service over HTTP, attaches a short-lived signed token to your forms, and verifies it with middleware. There is no account and no API key — you point it at the service and protect a route.
⚠️ Alpha — coming soon
Unbotable is in early testing. The protection works, but it's young: tuning is ongoing, the API may shift before 1.0, and it will not yet catch every bot. It's built to fail open — if anything goes wrong, your forms keep working and real users are never blocked by our bug. Pin an exact version and treat it as defense-in-depth, not a guarantee, until we cut a stable release.
How it works
@unbotableJsloads a small script that measures device signals at submit time and asks the Unbotable service to assess them.- The service returns a signed, 5-minute token carrying a provisional verdict — provisional because the honeypot and the timing floor do not exist yet; the form has not been submitted.
- On submit, the middleware gathers what it can see locally — honeypot, timing, token reuse — and asks the service to decide. The service holds both halves and answers; the middleware obeys.
The middleware may allow on the service's signed say-so, but it never denies on its own, except during an outage it detected itself. Keeping the decision in one place is what lets evidence one site gathers protect every other site, and it is why there is no longer a status field the browser can set to claim the service was unreachable.
For a clean signed pass with nothing new to report, the middleware short-
circuits and makes no network call at all, so the common path stays local.
A local honeypot and timing floor run independently of the service, so even if Unbotable is unreachable you keep a baseline of protection.
Requirements
- PHP 8.2+
- Laravel 12 or 13
Installation
composer require unbotable/unbotable-laravel
The service provider, the unbotable middleware alias, and the Blade directives
register automatically via Laravel package discovery.
Point the package at the service (defaults to the public service):
UNBOTABLE_URL=https://unbotable.com
Optionally publish the config to tune behavior:
php artisan vendor:publish --tag=unbotable-config
Quick start
Protect a route
use Illuminate\Support\Facades\Route; Route::post('/login', [LoginController::class, 'store'])->middleware('unbotable'); // During rollout, observe without enforcing: Route::post('/register', [RegisterController::class, 'store'])->middleware('unbotable:log_only');
Blade forms
Add the script once in your root layout, then drop the honeypot into the form:
{{-- layout <head> or before </body> --}} @unbotableJs <form method="POST" action="/login"> @csrf @unbotableHoneypot {{-- hidden trap field --}} @unbotableTimestamp {{-- JS-free timing floor --}} {{-- ...your fields... --}} </form>
For a plain Blade form with no framework, auto-wire it — the token is attached on submit:
@unbotableWire('#login-form')
@unbotableTimestamp renders a signed hidden field, which is the JS-free part
of the floor. If your form is rendered by JavaScript rather than Blade, use
@unbotableTimestampMeta in the layout <head> instead — see below.
Inertia / Vue
Add @unbotableJs and @unbotableTimestampMeta to your root Blade layout,
then use the composable. The meta tag is how the timing floor reaches an SPA at
all: an Inertia form serialises its own data and never sees a hidden input
sitting in the DOM, so without it there is no timing floor on that form.
The cleanest way to import the composable is a Vite alias to the installed
package (no copied files to drift). Note this means composer update changes
your front-end source — always run your asset build afterwards, or you ship
new middleware against a stale bundle:
// vite.config.js import { fileURLToPath } from 'node:url' export default defineConfig({ resolve: { alias: { '@unbotable': fileURLToPath( new URL('./vendor/unbotable/unbotable-laravel/resources/js', import.meta.url) ), }, }, })
<script setup> import { useForm } from '@inertiajs/vue3' import { useUnbotable } from '@unbotable/useUnbotable' const unbotable = useUnbotable() const form = useForm({ email: '', password: '', _unbotable_hp: '' }) const submit = async () => { await unbotable.settled() // assesses now, with real behavior unbotable.protect(form).post(route('login')) // folds in token, timestamp, challenge } </script> <template> <form @submit.prevent="submit"> <!-- ...your fields... --> <!-- off-screen honeypot --> <input type="text" name="_unbotable_hp" v-model="form._unbotable_hp" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" /> </form> </template>
Configuration
All settings have sensible defaults; override via env or the published config.
| Key (env) | Default | What it does |
|---|---|---|
UNBOTABLE_URL |
https://unbotable.com |
The Unbotable service to verify against |
UNBOTABLE_SITE_ID |
null |
Names this site, so one device's reach across the network can be counted. An identifier, not a credential — an unknown one is treated as unjudgeable, never as suspicious |
UNBOTABLE_ON_BLOCK |
fake_success |
On a denial: fake_success (redirect back with a flag), abort (422), or log_only |
UNBOTABLE_ON_MISSING_TOKEN |
require_js |
A submission with no token at all — usually JavaScript disabled: require_js (a plain "JavaScript is required" error), block, or log_only |
UNBOTABLE_MISSING_TOKEN_ERROR_FIELD |
null |
Which validation key that message attaches to. Scaffolds only render error keys they know about, so point this at a field your form displays or the message is invisible |
UNBOTABLE_ON_UNREACHABLE |
open |
If we cannot reach the service: open (allow + log, rely on honeypot/timing) or closed (deny) |
UNBOTABLE_VERDICT_TIMEOUT |
3 |
Seconds to wait for a decision before treating the service as down. Sits on the submit path, so keep it short |
UNBOTABLE_MIN_SUBMIT_SECONDS |
2 |
Submissions faster than this are reported as a timing violation (JS-free) |
UNBOTABLE_REPLAY_MAX_USES |
3 |
How many times one token may be reused before it is reported as replayed |
UNBOTABLE_INSPECT_CONTENT |
false |
Derive spam signals from free-text fields. Off by default — a login has nothing to inspect. See Content signals |
UNBOTABLE_CONTENT_MIN_LENGTH |
40 |
Minimum length before an auto-detected field counts as prose |
UNBOTABLE_FAKE_SUCCESS_REDIRECT |
null |
Where fake_success redirects (null = back) |
fake_success sets the session key _unbotable_ok so your view can show a
plausible success message to a bot while quietly dropping the submission.
Content signals
For forms carrying free text — a contact form, a comment box — Unbotable can use the strongest signal available against form spam: a spammer can rotate addresses and fingerprints for nothing, but cannot rotate the domain they are advertising, because advertising it is the entire point.
Set UNBOTABLE_INSPECT_CONTENT=true. The text never leaves your server. It
is reduced on your own machine to a link count, the domains those links point at,
and a locality-sensitive fingerprint for spotting the same message blasted at
many sites; only those numbers are sent. Credentials, tokens and the package's
own fields are never inspected.
Name your text fields if you can — 'content_fields' => ['message'] — otherwise
anything long enough to be prose is used, which is the mode a generic form
interceptor has to work in.
Fail-open by design
If the service times out or errors, the middleware applies on_unreachable
(default: allow), having established unreachability itself — it is not told
so by the browser. That distinction matters: this used to be a form field, which
meant posting _unbotable_status=unreachable walked straight past the whole
system. The field no longer exists.
A genuine denial always denies; only outages fail open. And a denial the visitor can fix by trying again — submitting faster than the floor, or reusing a token — says so, rather than faking success at someone whose only mistake was being quick.
Privacy
No cookies, no advertising company, no analytics beacon. Your visitors are never asked to solve anything.
What the service keeps is an irreversible hash of browser characteristics plus a few counters — how long it has been seen around, how often it submits, how many sites it appears on, whether it ever filled a hidden field. Nothing in it can be turned back into a device or a person, and it expires on its own.
Stated plainly, because it is the part most tools gloss over: that record is cross-site. A browser seen on two Unbotable-protected sites is recognisable as the same browser to the service — that is how a bot caught on one site gets stopped on the next. It does not follow anyone anywhere else, it is not linked to a name or an account, and no advertiser ever touches it.
See the live data at unbotable.com.
License
MIT — see LICENSE.