BugBoardDocs

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.terminate listener 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_logvar/log/dev.log under a default setup. Keys are always redacted.

  • Nothing arrives. Most often: never flushed. Without the kernel.terminate listener, under a worker runtime, or in a Messenger worker, nothing drains the buffer. Then check credentials (HMAC needs both key_id and signing_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_ms and max_retries.
  • Cards multiply instead of counting up. Interpolated ids or timestamps in titles.

Next steps