unbotable/unbotable-laravel

Laravel integration for Unbotable — privacy-respecting bot and spam protection

Maintainers

Package info

github.com/Unbotable/unbotable-laravel

Homepage

Issues

pkg:composer/unbotable/unbotable-laravel

Transparency log

Statistics

Installs: 48

Dependents: 0

Suggesters: 0

Stars: 0

v0.2.0 2026-08-25 03:31 UTC

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

  1. @unbotableJs loads a small script that measures device signals at submit time and asks the Unbotable service to assess them.
  2. 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.
  3. 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.