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" }
}