Guide · Laravel 13 + Fortify

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

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.

Next.jsWordPressClerkAuth0Supabase AuthAuth.js / NextAuthFirebase Auth

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.

Help