← All docs

Symfony

antibot/symfony is the PHP guard as a Symfony bundle. It assesses the requests your application protects, on the kernel's request event, and acts on the answer before your controller or your firewall runs: a refusal is the response, and the controller never sees the request. A sign-in's outcome is read from your own response at kernel.terminate, so you rarely need to report one yourself.

It needs PHP 8.2 or later and Symfony 7.4 (the current LTS) or 8.x. Its tests run against Symfony 7.4 on PHP 8.3 and Symfony 8.1 on PHP 8.4.

Install

composer require antibot/symfony

Register the bundle in config/bundles.php:

return [
    // ...
    Antibot\Symfony\AntibotBundle::class => ['all' => true],
];

Add your key to .env:

ANTIBOT_SECRET_KEY=...     # a key that may assess and nothing else

Nothing is assessed until the bundle is registered. Without ANTIBOT_SECRET_KEY it does nothing and logs one warning through your PSR-3 logger, so a missing key never takes the site down.

Configure

Every setting has a default, so an application that sets only the key needs no file. To change anything, create config/packages/antibot.yaml:

antibot:
    # The key is read from ANTIBOT_SECRET_KEY by default; say it here only to override.
    # session_keys: the list of your session keys, if you have them: receipts then verify without a call.

    # Your sign-in forms: the path, and the account field as the request carries it.
    logins:
        - { path: /login, field: _username }       # Symfony's form_login default: change it to your field
        - { path: /api/login, field: 'user[email]' }

    protect: ['/shop', '/api']   # path prefixes; omit to protect every path

The full set, with its defaults:

Setting Default
secret_key %env(default::ANTIBOT_SECRET_KEY)%, empty: requests pass, one warning logged The key that may assess
endpoint https://api.antibot.dev
timeout 2 Seconds to wait for an assessment before the request goes on unassessed
session_keys []: every request is assessed Session keys that verify a receipt without a call
protect null: every path Path prefixes to protect; configured forms are always covered
missing allow challenge interstitials a page navigation without a receipt
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 false
max_score 70 A receipt scored at or above this is refused
monitor false Monitor mode: decide everything, act on nothing; see below
block_signals null: bot Signals that condemn a receipt whatever its score; [] for none
cookie _abid The cookie a receipt arrives in
token_header antibot-token The header a receipt may arrive in instead; '' turns it off
flows {} The steps of your flows by name, each "[METHOD ]path[@minMs]" in order: checkout: ["POST /cart/checkout", "/checkout/delivery", "/checkout/payment@3000"] (steps taken faster than a person)
graphql, graphql_persisted [], {} Your GraphQL endpoints (["/graphql"]), and your trusted documents, id to text (GraphQL: one request that is many)
session_cookie null Your session cookie by name (PHPSESSID, or your framework.session.name): sent as a digest, never the cookie, so a signed-in session is bound to the device and browser that first used it (a signed-in session, bound to its device)
bound_paths [] Prefixes only your pages call: there only a receipt bound to the page's key counts, with a fresh signature by it (API calls only your pages can make). Needs session_keys
origin the request's own The site's origin, when TLS ends upstream and the proxy is not configured
logins [{path: /login, field: _username}] Sign-in forms: path and field, a field null is assessed and never reported
codes [] Gift card, voucher and coupon redemptions: path and field
contents [] Comment, review and contact forms: path and field
challenge off public_key (from ANTIBOT_PUBLIC_KEY, if you set it), agent_url, endpoint: the interstitial's settings

A sign-in's account is the field's value, read the way the request carries it: _username is a form field, user[email] the same field nested, in a form or in JSON, and user.email the JSON path. A multipart form is read the same way. 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.

Trusted proxies

The visitor's address is $request->getClientIp(), which Symfony reads through framework.trusted_proxies. Behind a load balancer, CDN or reverse proxy, name that proxy and nothing else, in config/packages/framework.yaml:

framework:
    trusted_proxies: '10.0.0.10'   # your load balancer's address
    trusted_headers:
        - x-forwarded-for
        - x-forwarded-host
        - x-forwarded-port
        - x-forwarded-proto

Never trust every address 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

The guard reads a sign-in's outcome from the response, once it has been 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.

Symfony's form_login fails by redirecting back to the login path and succeeds by redirecting elsewhere, so it needs nothing from you once the logins entry names the form's field. The default field is _username, which is form_login's default; if your form posts email, say so:

antibot:
    logins:
        - { path: /login, field: email }

Set field: null for a form whose redirect back to itself would read as a failure against the account (a password reset request, say): it is still assessed and challenged, and nothing is read or reported.

Telling the app what happened

When your code knows better than the redirect, say so, and the response is no longer inspected. The state is on the request:

use Antibot\GuardState;
use Symfony\Component\HttpFoundation\Request;

public function login(Request $request): Response
{
    $state = $request->attributes->get('antibot');   // a GuardState, or null
    $ok = $this->authenticate($request);

    if ($state instanceof GuardState) {
        $state->outcome($ok ? 'success' : 'failure');
    }

    return $ok ? $this->redirectToRoute('home') : $this->redirectToRoute('login');
}

With form_login the controller never runs, so report from the security event instead: LoginSuccessEvent and LoginFailureEvent both carry the request, and its antibot attribute is the same state.

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 controller sees

On a request the bundle passes on, $request->attributes->get('antibot') is the Antibot\GuardState. It is null where the bundle did not run: no key, or a request that was not a main request.

Member
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 controller or the firewall. The response is the guard's: a 403 with error.code set to Blocked, or the interstitial for a page navigation that must pass a challenge.

Monitor mode

Set monitor: true under antibot: 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 controller, and the antibot request attribute's state has wouldAct set to ['action' => 'block' | 'challenge', 'reason' => …], the reason being what the guard would have answered with (signal:bot, assess:block, 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 set it back to false.

When the API is out of reach

A failed assessment never blocks your site. The request goes on unassessed, the failure is written to your PSR-3 logger at warning, and the state's verdict is null. A failed outcome report is logged the same way.

Development

The bundle's own tests are in integrations/symfony of the repository; see its README.