Search the docs

Find a page or a section of one

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

bash
composer require antibot/laravel

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

bash
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:

php
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:

php
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:

php
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:

PathAccount fieldNotes
/loginemailBreeze, Jetstream and Fortify
/registeremail
/forgot-passwordnoneassessed and challenged, nothing read (see below)
/two-factor-challengenoneassessed 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:

php
'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:

php
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
verdictThe assessment: action, score, reasons, requestId; null where nothing was assessed (an unprotected path, or the API out of reach)
receiptThe verified receipt's claims, when the request carried one
ipThe visitor's address, as assessed
fieldThe 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:

bash
php artisan vendor:publish --tag=antibot-config
SettingEnvironmentDefault
secret_keyANTIBOT_SECRET_KEYnone: requests pass, one warning logged
endpointANTIBOT_ENDPOINThttps://api.layersift.com
timeoutANTIBOT_TIMEOUT2 seconds, then the request goes on unassessed
session_keysANTIBOT_SESSION_KEYSnone: every request is assessed
protectANTIBOT_PROTECTevery path; comma-separated prefixes, e.g. /shop,/api
missingANTIBOT_MISSINGallow; challenge interstitials a page navigation without a receipt
step_upANTIBOT_STEP_UPoff; 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_pagesANTIBOT_AGENT_ON_PAGESfalse
max_scoreANTIBOT_MAX_SCORE70
monitorANTIBOT_MONITORfalse; true acts on nothing, see below
block_signalsANTIBOT_BLOCK_SIGNALSbot; none for no signal
cookieANTIBOT_COOKIE_abid
token_headerANTIBOT_TOKEN_HEADERantibot-token
flowsANTIBOT_FLOWSnone — the steps of your flows, checkout=POST /cart/checkout, /checkout/delivery, /checkout/payment@3000 (steps taken faster than a person)
graphql, graphql_persistedANTIBOT_GRAPHQL, ANTIBOT_GRAPHQL_PERSISTEDnone — your GraphQL endpoints (/graphql), and your trusted documents as a JSON object of id to text (GraphQL: one request that is many)
sessionsANTIBOT_SESSIONSfalse — 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_pathsANTIBOT_BOUND_PATHSnone — prefixes only your pages call, where only a receipt bound to the page's key counts (API calls only your pages can make)
originANTIBOT_ORIGINthe request's own
logins, codes, contentsnonesee above; codes and contents are empty
challengeANTIBOT_PUBLIC_KEY, ANTIBOT_AGENT_URLnone: 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.