Symfony
Supported on Symfony 6.4 and 7.x. Setup is a bundle plus a few lines of YAML — but read Delivery before you ship: unlike Laravel, the bundle does not move delivery off the request path for you.
1. Install
composer require bugboard/sdk
Symfony projects usually have symfony/http-client, which is PSR-18 capable. The SDK only
enforces your configured timeoutMs on Guzzle or on a client you construct yourself — see
the HTTP client note if the timeout
matters to you.
2. Enable the bundle
// config/bundles.php return [ // … BugBoard\Symfony\BugBoardBundle::class => ['all' => true], ];
# config/packages/bugboard.yaml bugboard: key_id: '%env(BUGBOARD_KEY_ID)%' signing_secret: '%env(BUGBOARD_SIGNING_SECRET)%' environment: '%kernel.environment%'
# .env.local BUGBOARD_KEY_ID=bbk_xxxxxxxx BUGBOARD_SIGNING_SECRET=bb_sec_xxxxxxxx
The bundle registers bugboard.client and aliases BugBoard\Client to it, so the client is
autowirable anywhere.
Per-environment overrides work as usual:
# config/packages/dev/bugboard.yaml bugboard: enabled: false # or: log_locally: true debug: true # config/packages/prod/bugboard.yaml bugboard: sample_rate: 0.25 release: '%env(APP_RELEASE)%'
Run php bin/console config:dump-reference bugboard for the full schema with defaults, or
--format=yaml for a paste-ready file.
3. Report
Autowire BugBoard\Client by constructor injection:
use BugBoard\Client as BugBoardClient; final class CheckoutController extends AbstractController { public function __construct(private readonly BugBoardClient $bugboard) { } #[Route('/checkout', methods: ['POST'])] public function checkout(Request $request): Response { try { $this->payments->charge($request); } catch (\Throwable $e) { $this->bugboard->criticalHigh('Payment capture failed', $e, ['payments']); return $this->render('checkout/error.html.twig'); } return $this->redirectToRoute('checkout_success'); } }
The service is public, so $container->get('bugboard.client') works too — but autowiring is
the right default.
Delivery: what the bundle does and does not do
This is the most important difference from Laravel. The bundle registers the service and
nothing else. There is no kernel.terminate listener, so delivery falls back to the SDK's own
shutdown hook — which under PHP-FPM runs before the response reaches the client. Reporting
therefore adds latency to the request, exactly as in the
plain PHP case.
Add a terminate listener to get Laravel-like behaviour. kernel.terminate fires after the
response has been sent:
// src/EventListener/BugBoardFlushListener.php namespace App\EventListener; use BugBoard\Client as BugBoardClient; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpKernel\Event\TerminateEvent; use Symfony\Component\HttpKernel\KernelEvents; #[AsEventListener(event: KernelEvents::TERMINATE)] final class BugBoardFlushListener { public function __construct(private readonly BugBoardClient $bugboard) { } public function __invoke(TerminateEvent $event): void { $this->bugboard->flush(); } }
flush() on an empty buffer is a no-op, so this costs nothing on requests that reported
nothing. Injecting the client here does instantiate it on every request, which is negligible
(the constructor builds a buffer and a logger) — but if you would rather not, inject a
ServiceLocator and resolve it only when needed.
Worker runtimes. Under FrankenPHP worker mode, RoadRunner or Swoole the process does not end per request, so the shutdown hook effectively never fires. A
kernel.terminatelistener is not optional there — it is the only thing that delivers your reports.
Reporting every unhandled exception
// src/EventListener/ExceptionListener.php namespace App\EventListener; use BugBoard\Client as BugBoardClient; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; #[AsEventListener] final class ExceptionListener { public function __construct(private readonly BugBoardClient $bugboard) { } public function __invoke(ExceptionEvent $event): void { $exception = $event->getThrowable(); // 4xx are client mistakes, not your bugs — skip them. if ($exception instanceof HttpExceptionInterface && $exception->getStatusCode() < 500) { return; } $this->bugboard->critical( $exception->getMessage() ?: $exception::class, $exception, ['unhandled'], ); } }
$exception->getMessage() ?: $exception::class guards against an exception with an empty
message producing a card with an empty title.
Messenger: reporting failed messages
// src/EventListener/MessengerFailureListener.php namespace App\EventListener; use BugBoard\Client as BugBoardClient; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent; #[AsEventListener] final class MessengerFailureListener { public function __construct(private readonly BugBoardClient $bugboard) { } public function __invoke(WorkerMessageFailedEvent $event): void { if ($event->willRetry()) { return; // only report the final failure, not every attempt } $this->bugboard->major( 'Message handling failed: ' . $event->getEnvelope()->getMessage()::class, $event->getThrowable(), ['messenger'], ); // Workers are long-lived: the shutdown hook won't run for hours. $this->bugboard->flush(); } }
Both details matter. The willRetry() guard stops one flaky message producing four identical
reports, and the explicit flush() is required because a Messenger worker is a long-running
process — without it, a worker that reports faster than it exits will fill maxQueueSize
(default 100) and start dropping.
Monolog: routing existing logs to BugBoard
If you already log errors through Monolog, a custom handler puts them on your board without
touching any call sites. Be deliberate about the level — ERROR and up is usually right;
WARNING will flood you.
// src/Logging/BugBoardHandler.php namespace App\Logging; use BugBoard\Client as BugBoardClient; use Monolog\Handler\AbstractProcessingHandler; use Monolog\Level; use Monolog\LogRecord; final class BugBoardHandler extends AbstractProcessingHandler { public function __construct( private readonly BugBoardClient $bugboard, Level $level = Level::Error, ) { parent::__construct($level); } protected function write(LogRecord $record): void { $method = match (true) { $record->level->value >= Level::Critical->value => 'criticalHigh', $record->level->value >= Level::Error->value => 'major', default => 'moderate', }; $this->bugboard->{$method}( $record->message, // stable by design — placeholders live in context $record->context['exception'] ?? null, ['monolog', $record->channel], ); } }
# config/packages/prod/monolog.yaml monolog: handlers: bugboard: type: service id: App\Logging\BugBoardHandler level: error
Monolog keeps $record->message with its uninterpolated, which is an
excellent fit for server-side dedup: 'User {id} not found' is one card, not one per user.
Testing
Disable it in the test environment:
# config/packages/test/bugboard.yaml bugboard: enabled: false
To assert on reports, override the transport in the test container with a fake — see the fake transport in the Laravel guide for the class:
# config/services_test.yaml services: App\Tests\FakeTransport: public: true BugBoard\Client: public: true arguments: $config: '@bugboard.test_config' $transport: '@App\Tests\FakeTransport'
Remember to call flush() in the test before asserting — reports are buffered until then.
Troubleshooting
Turn on debug: true first. Output goes to error_log — var/log/dev.log under a default
setup. Keys are always redacted.
- Nothing arrives. Most often: never flushed. Without the
kernel.terminatelistener, under a worker runtime, or in a Messenger worker, nothing drains the buffer. Then check credentials (HMAC needs bothkey_idandsigning_secret),enabled, sampling,before_send, queue full, quota, or a 401/403 in the log. - Requests got slower after adding the SDK. You are flushing on the request path — add the
terminate listener. If you can't, bound the worst case with
timeout_msandmax_retries. - Cards multiply instead of counting up. Interpolated ids or timestamps in titles.
Next steps
- PHP SDK — the base guide and the full method surface.
- Laravel · Plain PHP — the other PHP integrations.
- Encrypting sensitive reports — seal payloads in transit.
- API Reference — every config option and the HTTP contract.