In Laravel, a bot check is
just another validation rule.
No middleware, no package, no new concept. A custom ValidationRule sits in the validator that Fortify's CreateNewUser action already runs, and a $fail() call rejects the registration exactly the way a bad email does. The user record is never created, and the framework answers in its own voice: 302 back with errors for Blade, 422 JSON for Inertia.
01The rule
// app/Rules/HumanVerified.php — php artisan make:rule HumanVerified
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class HumanVerified implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$signals = is_string($value) ? json_decode($value, true) : $value;
if (! is_array($signals)) {
$fail('Verification data is missing. Please enable JavaScript and try again.');
return;
}
try {
$response = Http::withToken(config('services.huma.key'))
->timeout(3) // the default is 30s, far too long inside a signup
->post('https://humaverify.com/api/v1/verify', [
'userId' => request()->input('email'),
'sessionData' => $signals,
]);
} catch (ConnectionException) {
Log::warning('useHUMA unreachable during signup; failing open.');
return; // our outage must not become your outage
}
// Laravel's HTTP client does NOT throw on 4xx/5xx. A non-2xx verify
// response fails open here too: only a real "human: false" blocks.
if ($response->successful() && $response->json('human') === false) {
$fail('We could not verify that you are human.');
}
}
}
// config/services.php — add:
'huma' => [
'key' => env('HUMA_API_KEY'),
],02Wire it into registration
Breeze and Jetstream have been maintenance-only since Laravel 12. The official starter kits all route POST /register through Fortify, so the canonical hook is the CreateNewUser action:
// app/Actions/Fortify/CreateNewUser.php — the canonical register hook.
// Every official Laravel 13 starter kit routes POST /register through
// Fortify, which calls this action.
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use App\Rules\HumanVerified;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules;
public function create(array $input): User
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => $this->passwordRules(),
// 'required' is load-bearing. See "What bites".
'huma_signals' => ['required', new HumanVerified],
])->validate();
return User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
}
}On a legacy Breeze app the same rule goes in RegisteredUserController@store's $request->validate() instead. Nothing else changes.
03Carry the signals
{{-- Blade form (apps without a starter kit): a hidden field
filled at submit time --}}
<input type="hidden" name="huma_signals" id="huma_signals">
<script src="https://humaverify.com/huma.js"></script>
<script>
document.getElementById('register-form').addEventListener('submit', function () {
document.getElementById('huma_signals').value =
JSON.stringify(window.Huma.debug().features);
});
</script>
// Starter kits (Inertia React/Vue/Livewire): no hidden input. Add
// huma_signals as one more property on the form object posted to
// POST /register; it lands in the same $input array the action validates.What bites
'required'is load-bearing. Laravel skips rule objects on fields absent from the input, so without it a bot that omitshuma_signalsskips verification entirely. This is the single most important line on this page.- Set
timeout(3). The HTTP client's default is 30 seconds, which inside a signup request means a hung verify call hangs the registration. - The HTTP client does not throw on 4xx or 5xx. The
$response->successful()guard is what keeps a misconfigured key from silently blocking every signup: a 401 fails open, not closed. - Laravel 13 keeps the slim skeleton from 11: no
app/Http/Kernel.php, bootstrapping inbootstrap/app.php, andapp/Rules/is created by the firstmake:rule. - If the check ever needs sibling fields, implement
DataAwareRuleor use a FormRequest'safter()hook rather than reaching forrequest()in more places. - In feature tests,
Http::fake()the humaverify.com domain and turn onHttp::preventStrayRequests()so the suite can never hit the live API.
Why this instead of a CAPTCHA
A CAPTCHA charges every legitimate user for the existence of bots that increasingly solve puzzles anyway. This charges nobody: the person types, and the decision happens on your server from what already happened. It also answers a question a CAPTCHA does not, which is whether the session was an AI agent driving a real browser rather than a script. What it does not catch is written down at /limits, before you build on it.
Laravel for the app, useHUMA for humanity.
Free plan: 1,000 verifications a month · no card · no expiry · AI-agent verdict included.
Get your API key →Class names, the Fortify action and the skeleton facts on this page were checked against Laravel's live documentation on 26 Aug 2026. If something has drifted, tell us at team@humaverify.com and it gets fixed.