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.