Symfony
Symfony

Introduction

Integrate Botbye bot protection into your Symfony application using event subscribers. This guide demonstrates how to protect your Symfony routes with dependency injection and event-driven architecture.

Installation

Install the SDK and a PSR-18 HTTP client via Composer:

1
composer require botbye/botbye-php-sdk symfony/http-client nyholm/psr7

Configuration

Configure the Botbye client as a service in config/services.yaml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
services:
    _defaults:
        autowire: true
        autoconfigure: true

    # PSR-17 Factory
    Nyholm\Psr7\Factory\Psr17Factory: ~

    # PSR-18 HTTP Client
    botbye.http_client:
        class: Symfony\Component\HttpClient\Psr18Client

    # Botbye Configuration
    Botbye\Client\BotbyeConfig:
        arguments:
            # Use your project server-key
            $serverKey: '00000000-0000-0000-0000-000000000000'

    # Botbye Client
    Botbye\Client\BotbyeClient:
        arguments:
            $config: '@Botbye\Client\BotbyeConfig'
            $httpClient: '@botbye.http_client'
            $requestFactory: '@Nyholm\Psr7\Factory\Psr17Factory'
            $streamFactory: '@Nyholm\Psr7\Factory\Psr17Factory'

    # Event Subscriber
    App\EventSubscriber\BotbyeSubscriber:
        tags:
            - { name: kernel.event_subscriber }

Usage

Event Subscriber Implementation

1. Create an event subscriber to evaluate requests:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<?php

namespace App\EventSubscriber;

use Botbye\Client\BotbyeClient;
use Botbye\Model\BotbyeValidationEvent;
use Botbye\Model\Headers;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\KernelEvents;

class BotbyeSubscriber implements EventSubscriberInterface
{
    public function __construct(private BotbyeClient $botbye) {}

    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::REQUEST => ['onKernelRequest', 10],
        ];
    }

    public function onKernelRequest(RequestEvent $event): void
    {
        // Only evaluate main requests, not sub-requests
        if (!$event->isMainRequest()) {
            return;
        }

        $request = $event->getRequest();

        // Skip evaluation for specific routes (e.g., health checks)
        if ($this->shouldSkipEvaluation($request->getPathInfo())) {
            return;
        }

        $headers = Headers::fromArray($request->headers->all());

        // Extract the token from wherever you pass it: query param, header, body, etc.
        $token = $request->query->get('botbye_token', '');

        $response = $this->botbye->evaluate(new BotbyeValidationEvent(
            ip: $request->getClientIp(),
            token: $token,
            headers: $headers->jsonSerialize(),
            requestMethod: $request->getMethod(),
            requestUri: $request->getRequestUri(),
        ));

        if ($response->isBlocked()) {
            throw new AccessDeniedHttpException('Access denied');
        }
    }

    private function shouldSkipEvaluation(string $path): bool
    {
        $skipPaths = [
            '/health',
            '/metrics',
            '/_profiler',
        ];

        foreach ($skipPaths as $skipPath) {
            if (str_starts_with($path, $skipPath)) {
                return true;
            }
        }

        return false;
    }
}

2. For more granular control, use route attributes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?php

namespace App\Controller;

use App\Attribute\BotbyeProtected;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;

class ApiController extends AbstractController
{
    #[Route('/api/checkout', methods: ['POST'])]
    #[BotbyeProtected]
    public function checkout(): JsonResponse
    {
        // Your checkout logic
        return $this->json(['status' => 'success']);
    }

    #[Route('/api/login', methods: ['POST'])]
    #[BotbyeProtected]
    public function login(): JsonResponse
    {
        // Your login logic
        return $this->json(['status' => 'success']);
    }
}

Create the attribute class:

1
2
3
4
5
6
7
8
<?php

namespace App\Attribute;

#[\Attribute(\Attribute::TARGET_METHOD)]
class BotbyeProtected
{
}

Update the subscriber to check for the attribute:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<?php

namespace App\EventSubscriber;

use App\Attribute\BotbyeProtected;
use Botbye\Client\BotbyeClient;
use Botbye\Model\BotbyeValidationEvent;
use Botbye\Model\Headers;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\KernelEvents;

class BotbyeSubscriber implements EventSubscriberInterface
{
    public function __construct(private BotbyeClient $botbye) {}

    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::CONTROLLER => 'onKernelController',
        ];
    }

    public function onKernelController(ControllerEvent $event): void
    {
        $controller = $event->getController();

        if (!is_array($controller)) {
            return;
        }

        $method = new \ReflectionMethod($controller[0], $controller[1]);
        $attributes = $method->getAttributes(BotbyeProtected::class);

        if (empty($attributes)) {
            return;
        }

        $request = $event->getRequest();

        $headers = Headers::fromArray($request->headers->all());

        // Extract the token from wherever you pass it: query param, header, body, etc.
        $token = $request->query->get('botbye_token', '');

        $response = $this->botbye->evaluate(new BotbyeValidationEvent(
            ip: $request->getClientIp(),
            token: $token,
            headers: $headers->jsonSerialize(),
            requestMethod: $request->getMethod(),
            requestUri: $request->getRequestUri(),
        ));

        if ($response->isBlocked()) {
            throw new AccessDeniedHttpException('Access denied');
        }
    }
}

There are three event types — validate, risk, and full — each suited for a different layer of your application.

validate — edge-level bot check

Use at the edge — event subscriber, middleware — when you just want to know: was this request made by a bot? No user or domain context needed. This is the event subscriber shown above:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<?php

namespace App\EventSubscriber;

use Botbye\Client\BotbyeClient;
use Botbye\Model\BotbyeValidationEvent;
use Botbye\Model\Headers;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\KernelEvents;

class BotbyeSubscriber implements EventSubscriberInterface
{
    public function __construct(private BotbyeClient $botbye) {}

    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::REQUEST => ['onKernelRequest', 10],
        ];
    }

    public function onKernelRequest(RequestEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return;
        }

        $request = $event->getRequest();
        $headers = Headers::fromArray($request->headers->all());

        // Extract the token from wherever you pass it: query param, header, body, etc.
        $token = $request->query->get('botbye_token', '');

        $response = $this->botbye->evaluate(new BotbyeValidationEvent(
            ip: $request->getClientIp(),
            token: $token,
            headers: $headers->jsonSerialize(),
            requestMethod: $request->getMethod(),
            requestUri: $request->getRequestUri(),
        ));

        if ($response->isBlocked()) {
            throw new AccessDeniedHttpException('Access denied');
        }
    }
}

risk — domain-level risk scoring

Use inside services that already know the user: auth, payments, account management, etc. The purpose shifts from "is this a bot?" to "is something suspicious happening for this user?" — credential stuffing, account takeover, account sharing, logins from a new geo.

event and user are the key fields here — they define what action is being performed and who is performing it, which is what drives the risk score.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<?php

namespace App\Service;

use Botbye\Client\BotbyeClient;
use Botbye\Model\BotbyeRiskScoringEvent;
use Botbye\Model\BotbyeUserInfo;
use Botbye\Model\EventStatus;
use Botbye\Model\Headers;
use Symfony\Component\HttpFoundation\Request;

class BotbyeRiskService
{
    public function __construct(private BotbyeClient $botbye) {}

    public function evaluateLogin(Request $request, string $userId, string $email, bool $loginSucceeded): void
    {
        $headers = Headers::fromArray($request->headers->all());

        $response = $this->botbye->evaluate(new BotbyeRiskScoringEvent(
            ip: $request->getClientIp(),
            headers: $headers->jsonSerialize(),
            user: new BotbyeUserInfo(
                accountId: $userId,
                email: $email,
            ),
            eventType: 'login',
            eventStatus: $loginSucceeded ? EventStatus::SUCCESSFUL : EventStatus::FAILED,
            botbyeResult: null,
        ));

        if ($response->decision === 'BLOCK') {
            // Lock account, trigger MFA, send alert, etc.
        }
    }
}

Linking validate and risk events

When the same request is evaluated at two layers — for example, once at the edge (validate) and then again inside a domain service (risk) — BotBye can link both events and display them as a single event in the dashboard.

Step 1 — edge layer (event subscriber or middleware): run validate and capture the result:

1
2
3
4
5
6
7
8
9
10
11
<?php
// e.g. in BotbyeSubscriber::onKernelRequest()
$edgeResponse = $this->botbye->evaluate(new BotbyeValidationEvent(
    ip: $request->getClientIp(),
    token: $request->query->get('botbye_token', ''),
    headers: Headers::fromArray($request->headers->all())->jsonSerialize(),
    requestMethod: $request->getMethod(),
    requestUri: $request->getRequestUri(),
));
$edgeBotbyeResult = $edgeResponse->botbyeResult;
// Pass $edgeBotbyeResult downstream — a request attribute, function argument, shared context, etc.

Step 2 — domain service (auth, payment, account management): pass it as botbyeResult in the risk call:

1
2
3
4
5
6
7
8
9
10
<?php
// e.g. in BotbyeRiskService::evaluateLogin()
$riskResponse = $this->botbye->evaluate(new BotbyeRiskScoringEvent(
    ip: $request->getClientIp(),
    headers: Headers::fromArray($request->headers->all())->jsonSerialize(),
    user: new BotbyeUserInfo(accountId: $userId, email: $email),
    eventType: 'login',
    eventStatus: $loginSucceeded ? EventStatus::SUCCESSFUL : EventStatus::FAILED,
    botbyeResult: $edgeBotbyeResult,
));

botbyeResult is null when absent — in that case, omit or pass null and the events will be recorded independently.

full — edge check and domain scoring in one call

Use when you have all context at once: raw request, token, user, and event. A login endpoint is a typical example — it receives the HTTP request and immediately knows the user and outcome.

Equivalent to running validate and risk in a single call.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<?php

namespace App\Controller;

use Botbye\Client\BotbyeClient;
use Botbye\Model\BotbyeFullEvent;
use Botbye\Model\BotbyeUserInfo;
use Botbye\Model\EventStatus;
use Botbye\Model\Headers;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;

class AuthController extends AbstractController
{
    public function __construct(private BotbyeClient $botbye) {}

    #[Route('/api/login', methods: ['POST'])]
    public function login(Request $request): JsonResponse
    {
        $email = $request->getPayload()->get('email');
        $password = $request->getPayload()->get('password');

        $user = $this->findUser($email);
        $loginSucceeded = $user && $this->checkPassword($user, $password);

        $headers = Headers::fromArray($request->headers->all());

        $result = $this->botbye->evaluate(new BotbyeFullEvent(
            ip: $request->getClientIp(),
            token: $request->query->get('botbye_token', ''),
            headers: $headers->jsonSerialize(),
            requestMethod: $request->getMethod(),
            requestUri: $request->getRequestUri(),
            user: new BotbyeUserInfo(
                accountId: $user?->getId() ?? 'unknown',
                email: $email,
            ),
            eventType: 'login',
            eventStatus: $loginSucceeded ? EventStatus::SUCCESSFUL : EventStatus::FAILED,
        ));

        if ($result->isBlocked()) {
            throw new AccessDeniedHttpException('Access denied');
        }

        // Proceed with login
        return $this->json(['status' => 'success']);
    }
}

Advanced Configuration

Custom HTTP Client

Configure a custom HTTP client with specific timeouts:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# config/services.yaml
services:
    # PSR-18 HTTP Client with custom timeouts
    botbye.http_client:
        class: Symfony\Component\HttpClient\Psr18Client
        arguments:
            - !service
                class: Symfony\Component\HttpClient\HttpClient
                factory: ['Symfony\Component\HttpClient\HttpClient', 'create']
                arguments:
                    -
                        timeout: 2.0
                        max_duration: 3.0
                        max_redirects: 0

    # Botbye Client with custom HTTP client
    Botbye\Client\BotbyeClient:
        arguments:
            $config: '@Botbye\Client\BotbyeConfig'
            $httpClient: '@botbye.http_client'
            $requestFactory: '@Nyholm\Psr7\Factory\Psr17Factory'
            $streamFactory: '@Nyholm\Psr7\Factory\Psr17Factory'

Logging Integration

Integrate with Symfony's Monolog:

1
2
3
4
5
6
7
8
9
10
11
# config/packages/monolog.yaml
monolog:
    channels:
        - botbye

    handlers:
        botbye:
            type: stream
            path: '%kernel.logs_dir%/botbye.log'
            level: warning
            channels: [botbye]

Configure the service:

1
2
3
4
5
6
7
8
9
# config/services.yaml
services:
    Botbye\Client\BotbyeClient:
        arguments:
            $config: '@Botbye\Client\BotbyeConfig'
            $httpClient: '@botbye.http_client'
            $requestFactory: '@Nyholm\Psr7\Factory\Psr17Factory'
            $streamFactory: '@Nyholm\Psr7\Factory\Psr17Factory'
            $logger: '@monolog.logger.botbye'

Best Practices

1. Service Configuration - Use Symfony's dependency injection for clean architecture

2. Event Priorities - Set appropriate event priorities to control execution order

3. Selective Protection - Use attributes or route patterns to protect specific endpoints

4. Logging - Use Monolog channels for organized logging

5. Environment Configuration - Use different configurations for dev/prod environments

Testing

Mock the Botbye client in your tests:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
<?php

namespace App\Tests\Controller;

use Botbye\Client\BotbyeClient;
use Botbye\Model\BotbyeEvaluateResponse;
use Botbye\Model\Decision;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class ApiControllerTest extends WebTestCase
{
    public function testCheckoutAllowed(): void
    {
        $client = static::createClient();

        // Mock Botbye client
        $botbyeMock = $this->createMock(BotbyeClient::class);
        $botbyeMock->method('evaluate')
            ->willReturn(new BotbyeEvaluateResponse(
                decision: Decision::ALLOW,
                riskScore: 0.05,
                signals: [],
                scores: ['bot' => 0.05],
            ));

        $client->getContainer()->set(BotbyeClient::class, $botbyeMock);

        $client->request('POST', '/api/checkout', [], [], [], json_encode([
            'item' => 'test',
        ]));

        $this->assertResponseIsSuccessful();
    }

    public function testCheckoutBlocked(): void
    {
        $client = static::createClient();

        // Mock Botbye client
        $botbyeMock = $this->createMock(BotbyeClient::class);
        $botbyeMock->method('evaluate')
            ->willReturn(new BotbyeEvaluateResponse(
                decision: Decision::BLOCK,
                riskScore: 0.95,
                signals: ['AutomationTool'],
                scores: ['bot' => 0.95],
            ));

        $client->getContainer()->set(BotbyeClient::class, $botbyeMock);

        $client->request('POST', '/api/checkout', [], [], [], json_encode([
            'item' => 'test',
        ]));

        $this->assertResponseStatusCodeSame(403);
    }
}

Examples of BotBye API responses

Blocked (bot detected):

1
2
3
4
5
6
7
{
  "request_id": "f77b2abd-c5d7-44f0-be4f-174b04876583",
  "decision": "BLOCK",
  "risk_score": 0.95,
  "scores": { "bot": 0.95 },
  "signals": ["AutomationTool"]
}

Allowed:

1
2
3
4
5
6
7
{
  "request_id": "f77b2abd-c5d7-44f0-be4f-174b04876583",
  "decision": "ALLOW",
  "risk_score": 0.05,
  "scores": { "bot": 0.05, "ato": 0.02 },
  "signals": []
}

Challenge:

1
2
3
4
5
6
7
8
{
  "request_id": "f77b2abd-c5d7-44f0-be4f-174b04876583",
  "decision": "CHALLENGE",
  "risk_score": 0.65,
  "scores": { "bot": 0.65 },
  "signals": ["SuspiciousFingerprint"],
  "challenge": { "type": "captcha", "token": "..." }
}

Invalid server-key:

1
2
3
4
{
  "decision": "ALLOW",
  "error": { "message": "[BotBye] Bad Request: Invalid Server Key" }
}