← All docs

Laravel

antibot/laravel is the PHP guard as Laravel middleware. It assesses the requests your application protects and acts on the answer before your route runs: a refusal is the response, and the route never sees the request. A sign-in's outcome is read from your own response, so you rarely need to report one yourself.

It needs PHP 8.2 or later and Laravel 11, 12 or 13. Its tests run against Laravel 12 and 13.

Install

composer require antibot/laravel

The package is discovered automatically. Add your keys to .env:

ANTIBOT_SECRET_KEY=...     # a key that may assess and nothing else
ANTIBOT_PUBLIC_KEY=...     # optional: the agent key, for the interstitial
ANTIBOT_SESSION_KEYS=...   # optional: your session keys, comma separated

Nothing is protected until the middleware is in place. Without ANTIBOT_SECRET_KEY it passes every request on and logs one warning, so a missing key never takes the site down.

Add the middleware

Laravel 11 and later, in bootstrap/app.php, for every request:

use Antibot\Laravel\Http\Middleware\Antibot;
use Illuminate\Foundation\Configuration\Middleware;

->withMiddleware(function (Middleware $middleware) {
    $middleware->append(Antibot::class);
})

Or per route, under the alias the package registers:

Route::post('/login', [LoginController::class, 'store'])->middleware('antibot');

Global is the usual choice: a path nobody protects is passed on without an assessment, and the forms you configure are covered whichever route group they are in. A form is its path as written — /login, read however the request spells it — so a localized route (/{locale}/login) is one entry for each locale's path.

Trusted proxies

The visitor's address is $request->ip(), which Laravel reads through its trusted-proxy configuration. Behind a load balancer, CDN or reverse proxy, name that proxy, and nothing else:

use Illuminate\Http\Request;

->withMiddleware(function (Middleware $middleware) {
    $middleware->trustProxies(
        at: ['10.0.0.10'],   // your load balancer's address
        headers: Request::HEADER_X_FORWARDED_FOR
            | Request::HEADER_X_FORWARDED_HOST
            | Request::HEADER_X_FORWARDED_PORT
            | Request::HEADER_X_FORWARDED_PROTO,
    );
    $middleware->append(Antibot::class);
})

Never at: '*' on an application a client can reach directly: then any client can write the forwarded address the assessment believes. Without trusted proxies the address is the connection's, and a forwarded header is ignored.

Sign-ins

By default the middleware assesses these forms, and reads their outcome:

Path Account field Notes
/login email Breeze, Jetstream and Fortify
/register email
/forgot-password none assessed and challenged, nothing read (see below)
/two-factor-challenge none assessed and challenged, nothing read

The account is sent with the assessment, and the outcome is reported after the response is sent:

  • a redirect back to the form, or a 401 or 422, is a failure;
  • any other redirect, or a JSON 2xx, is a success.

Breeze, Jetstream and Fortify redirect on success to the application's home page and on failure back to the form, so the defaults need nothing from you.

Laravel's own authentication says it first, whatever the response: on a configured sign-in, its Failed event (a wrong password) and Lockout (too many tries) are reported as failures and Login as a success — so a sign-in answered with a 204 (Breeze's API) or a page that says nothing is counted too. Fortify's TwoFactorAuthenticationChallenged, a right password with a second factor still to come, is neither: nothing is reported, and its redirect to /two-factor-challenge is not read as a success. An attempt's outcome is counted once, so a route that also says it adds nothing.

/forgot-password redirects back to itself on success too, which would read as a failed sign-in against the account someone asked a reset for — so it has no field: the request is still assessed and challenged, and nothing is read or reported.

Fields your app spells differently

A field is read the way the request carries it. email is a form field; user[email] is the same field nested, in a form or in JSON; user.email is the JSON path. Set the field to match your form:

'logins' => [['path' => '/api/login', 'field' => 'user[email]']],

A multipart form is read the same way, whether PHP or the request parsed it. A JSON number is read as an account or a code only when it is a whole number within 2^53 − 1 (9007199254740991); a bigger one, or one with a fraction, is not read.

Paths are the ones your routes match. Behind a front controller (/index.php/login) or in a subdirectory (/myapp/login), the guard reads /login, the path the router matched, as well as the URL the client sent: the entries above and protect apply to the same requests they apply to in your routes file, and a path written whole (/myapp/login) does too. The assessed URL, and a phone's signed request, are the one the client sent. The query string is given as the client sent it, so a field carried there is read too.

Telling the app what happened

The redirect is a guess. When your code knows better, say so, and the response is no longer inspected:

public function store(Request $request)
{
    $ok = Auth::attempt($request->only('email', 'password'));

    $request->antibot()?->outcome($ok ? 'success' : 'failure');

    return $ok ? redirect()->intended('/dashboard') : back();
}

outcome() does nothing on a request that is not a configured form. Only a success is remembered as a device the account signs in from, so report 'success' only for a sign-in that succeeded.

What the route sees

On a request the middleware passes on, $request->antibot() returns the decision. It is null where the middleware did not run: no key, or a route outside it.

Property
verdict The assessment: action, score, reasons, requestId; null where nothing was assessed (an unprotected path, or the API out of reach)
receipt The verified receipt's claims, when the request carried one
ip The visitor's address, as assessed
field The form's account, as assessed
outcome('success' | 'failure') Report how a sign-in went

A refused request never reaches the route. The response is the guard's: a 403 with error.code set to Blocked and a reference the visitor can quote, or the interstitial for a page navigation that must pass a challenge.

Configuration

Publish the file to change anything beyond the environment:

php artisan vendor:publish --tag=antibot-config
Setting Environment Default
secret_key ANTIBOT_SECRET_KEY none: requests pass, one warning logged
endpoint ANTIBOT_ENDPOINT https://api.antibot.dev
timeout ANTIBOT_TIMEOUT 2 seconds, then the request goes on unassessed
session_keys ANTIBOT_SESSION_KEYS none: every request is assessed
protect ANTIBOT_PROTECT every path; comma-separated prefixes, e.g. /shop,/api
missing ANTIBOT_MISSING allow; challenge interstitials a page navigation without a receipt
step_up ANTIBOT_STEP_UP off; hold puts a press-and-hold before a page navigation whose receipt the verdict asked to step up (the edge's step-up); hold-all refuses its API calls too until the hold is passed; needs challenge
agent_on_pages ANTIBOT_AGENT_ON_PAGES false
max_score ANTIBOT_MAX_SCORE 70
monitor ANTIBOT_MONITOR false; true acts on nothing, see below
block_signals ANTIBOT_BLOCK_SIGNALS bot; none for no signal
cookie ANTIBOT_COOKIE _abid
token_header ANTIBOT_TOKEN_HEADER antibot-token
flows ANTIBOT_FLOWS none — the steps of your flows, checkout=POST /cart/checkout, /checkout/delivery, /checkout/payment@3000 (steps taken faster than a person)
graphql, graphql_persisted ANTIBOT_GRAPHQL, ANTIBOT_GRAPHQL_PERSISTED none — your GraphQL endpoints (/graphql), and your trusted documents as a JSON object of id to text (GraphQL: one request that is many)
sessions ANTIBOT_SESSIONS false — true binds each session to the device and browser that first used it, and says when its cookie is used from another (a signed-in session, bound to its device). The session's id is read — decrypted from its cookie, which Laravel encrypts anew on every response — and sent as a digest, never as it is
bound_paths ANTIBOT_BOUND_PATHS none — prefixes only your pages call, where only a receipt bound to the page's key counts (API calls only your pages can make)
origin ANTIBOT_ORIGIN the request's own
logins, codes, contents none see above; codes and contents are empty
challenge ANTIBOT_PUBLIC_KEY, ANTIBOT_AGENT_URL none: the interstitial is off

codes (gift cards, vouchers, coupons) and contents (comments, reviews, contact forms) take the same ['path' => …, 'field' => …] entries as logins. A redemption's outcome is read from the response by its own rules: 400, 404, 409, 410 and 422 a refusal, a JSON 2xx an acceptance, anything else nothing — a redirect too, since after post-redirect-get a refusal and an acceptance both redirect, so a redemption that redirects reports its outcome with $request->antibot()?->outcome(...). A message is assessed with its text, and the middleware reports no outcome for it: whether a message was spam is a moderator's call, which $request->antibot() does not take.

Monitor mode

Set ANTIBOT_MONITOR=true to see what enforcing would do before it does. Every request is decided as usual, but nothing is refused or challenged: a request that would have been refused or sent to the interstitial reaches the route, and $request->antibot()->wouldAct says what would have happened, as ['action' => 'block' | 'challenge', 'reason' => …] (the reason is what the guard would have answered with, such as signal:bot, assess:block or missing:challenge); it is null when nothing would have been done. Sign-in outcomes are still reported, and every assessment is sent to the API marked as monitored, so the API keeps it as what would have been done, not as done. Use it for a site's first weeks, then unset it.

Octane

Under Octane the middleware's terminate() runs after the response has gone, as it does under PHP-FPM, and the guard's bookkeeping (usage of receipts, revocations) is done there. Set ANTIBOT_SESSION_KEYS so a request carrying a valid receipt passes without a call. Nothing else changes: the decision is made per request, and the state lives on the request.

When the API is out of reach

A failed assessment never blocks your site. The request goes on unassessed, the failure is written to the log at warning, and the route's $request->antibot() has no verdict. A failed outcome report is logged the same way.