# Symfony

Supported on Symfony 6.4 and 7.x. Setup is a bundle plus a few lines of YAML — but read
**[Delivery](#delivery-what-the-bundle-does-and-does-not-do)** before you ship: unlike Laravel,
the bundle does **not** move delivery off the request path for you.

## 1. Install

```bash
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](/docs/frameworks/plain-php#about-the-http-client)** if the timeout
matters to you.

## 2. Enable the bundle

```php
// config/bundles.php
return [
    // …
    BugBoard\Symfony\BugBoardBundle::class => ['all' => true],
];
```

```yaml
# config/packages/bugboard.yaml
bugboard:
    key_id: '%env(BUGBOARD_KEY_ID)%'
    signing_secret: '%env(BUGBOARD_SIGNING_SECRET)%'
    environment: '%kernel.environment%'
```

```dotenv
# .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:

```yaml
# 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:

```php
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](/docs/frameworks/plain-php#delivery-where-the-shutdown-hook-fires)** case.

Add a terminate listener to get Laravel-like behaviour. `kernel.terminate` fires after the
response has been sent:

```php
// 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

```php
// 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

```php
// 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.

```php
// 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],
        );
    }
}
```

```yaml
# config/packages/prod/monolog.yaml
monolog:
    handlers:
        bugboard:
            type: service
            id: App\Logging\BugBoardHandler
            level: error
```

Monolog keeps `$record->message` with its `{placeholders}` 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:

```yaml
# 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](/docs/frameworks/laravel#testing)** for the class:

```yaml
# 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.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

- **[PHP SDK](/docs/sdks/php)** — the base guide and the full method surface.
- **[Laravel](/docs/frameworks/laravel)** · **[Plain PHP](/docs/frameworks/plain-php)** — the
  other PHP integrations.
- **[Encrypting sensitive reports](/docs/security/encryption)** — seal payloads in transit.
- **[API Reference](/docs/api-reference)** — every config option and the HTTP contract.
