bugboard / sdk
Official BugBoard SDK for PHP — report errors as cards on your project board, with first-class Laravel and Symfony support.
Requires
- php: ^8.2
- ext-json: *
- php-http/discovery: ^1.19
- psr/http-client: ^1.0
- psr/http-client-implementation: ^1.0
- psr/http-factory: ^1.0
- psr/http-factory-implementation: ^1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.8
- laravel/pint: ^1.21
- orchestra/testbench: ^9.0 || ^10.0
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5
- symfony/config: ^6.4 || ^7.0
- symfony/dependency-injection: ^6.4 || ^7.0
- symfony/http-kernel: ^6.4 || ^7.0
Suggests
- ext-sodium: Required for optional payload encryption (sealed boxes).
- guzzlehttp/guzzle: A PSR-18 HTTP client the SDK auto-discovers (any PSR-18 client works).
README
The official BugBoard SDK for PHP. Report errors as cards on your project board — from plain PHP, Laravel, Symfony, or any framework with a PSR-18 HTTP client.
🚧 WORK IN PROGRESS 🚧
BugBoard.dev and its SDKs are currently under active development. 🚨 Please do not use this package for anything right now. 🚨 Once an official release is published, it will be available for everyone.
use BugBoard\Laravel\Facades\BugBoard; try { $payments->charge($order); } catch (\Throwable $e) { BugBoard::criticalHigh('Payment failed', $e, ['payment', 'backend']); }
Reporting is fire-and-forget: the call buffers the report and returns immediately, delivery happens after your response is sent (with retries and backoff), and the SDK never throws into your app.
Requirements
- PHP 8.2+
- Any PSR-18 HTTP client plus PSR-17 factories. Guzzle is
the recommendation (
composer require guzzlehttp/guzzle); if you don't pick one,php-http/discoveryfinds whatever PSR-18 client your project already has. ext-sodiumonly if you enable payload encryption (bundled with PHP by default)
Installation
composer require bugboard/sdk
Set your credentials in .env (or the environment). Servers use a secret key — a key id
(bbk_…) plus a signing secret (bb_sec_…). Every request is HMAC-signed; the secret never
travels on the wire:
BUGBOARD_KEY_ID=bbk_xxxxxxxx BUGBOARD_SIGNING_SECRET=bb_sec_xxxxxxxx
Get keys from your BugBoard project under Settings → API Keys (create a Secret key).
Installing php-sodium (for payload encryption)
ext-sodium ships with PHP by default on most systems, but if you enable payload encryption (or php -m doesn't list sodium), install and configure it:
1. Install the extension
Match the package to the PHP version you actually run (php -v):
Debian / Ubuntu
sudo apt update sudo apt install php8.3-sodium
RHEL / CentOS / Rocky / AlmaLinux / Fedora
sudo dnf install php-sodium
Alpine
apk add php83-sodium
macOS (Homebrew)
Homebrew's PHP already includes sodium, so there is usually nothing to do:
brew install php
Docker
On the official php images, libsodium's headers aren't in the base image, so install them and compile the extension:
RUN apt-get update \
&& apt-get install -y libsodium-dev \
&& docker-php-ext-install sodium
Windows
php_sodium.dll ships with the official builds. Nothing to install; go straight to step 3.
Building PHP from source
Configure with --with-sodium.
2. Enable it
The Debian/Ubuntu and RHEL packages normally enable the extension for you. If php -m still doesn't list it, load it explicitly.
On Debian / Ubuntu:
sudo phpenmod sodium
Anywhere else (and on Windows), add this line to php.ini — run php --ini to find which file is in use:
extension=sodium
3. Restart the process that serves your app
The extension is loaded at startup, so a running worker won't pick it up:
sudo systemctl restart php8.3-fpm # PHP-FPM sudo systemctl restart apache2 # mod_php
The gotcha: The CLI and FPM usually read different php.ini files.
php -mtells you about the CLI only, so it can happily print sodium while your web requests still crash. Check the runtime your app actually uses:php-fpm -m | grep sodium— or hit a
phpinfo()page and search for "sodium".
4. Verify a sealed box actually works
The real proof is a round trip, not just a loaded extension:
php -r ' $keypair = sodium_crypto_box_keypair(); $sealed = sodium_crypto_box_seal("hello", sodium_crypto_box_publickey($keypair)); echo sodium_crypto_box_seal_open($sealed, $keypair), PHP_EOL; '
If it prints hello, sealed boxes work and the SDK can encrypt. If it fatals on an undefined function, the extension still isn't loaded in that runtime — go back to step 3.
Laravel
The package is auto-discovered — no manual registration. Configure via .env (above) and report
from anywhere using the facade:
use BugBoard\Laravel\Facades\BugBoard; BugBoard::major('Checkout is slow'); // a title is all you need BugBoard::critical('Payment failed', $e); // attach the caught Throwable BugBoard::critical('Payment failed', $e, ['payments', 'checkout']);
Or inject the shared client instead of using the facade:
use BugBoard\Client as BugBoardClient; public function store(Request $request, BugBoardClient $bugboard) { $bugboard->moderate('Slow image upload', null, 'uploads'); }
Buffered reports are delivered when the app terminates — after the response has gone out — so reporting never adds latency to a request. To customize defaults, publish the config:
php artisan vendor:publish --tag=bugboard-config
A good default for config/bugboard.php is already provided: every option it exposes is env-driven
(BUGBOARD_ENABLED, BUGBOARD_SAMPLE_RATE, BUGBOARD_DEBUG, …) and cards are tagged with your
APP_ENV out of the box.
beforeSend is the one option env can't express — it's a closure, so add it to the published config
file directly:
// config/bugboard.php 'before_send' => function (array $payload): ?array { $payload['description'] = preg_replace('/\S+@\S+/', '[email]', $payload['description'] ?? '') ?: null; return $payload; // or return null to drop the report },
Want every unhandled exception on your board? Add one line to your exception handler:
// bootstrap/app.php (Laravel 11+) ->withExceptions(function (Exceptions $exceptions) { $exceptions->report(function (\Throwable $e) { \BugBoard\Laravel\Facades\BugBoard::critical($e->getMessage() ?: $e::class, $e); }); })
Symfony
Enable the bundle and configure it:
// 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%'
Then autowire the client anywhere:
use BugBoard\Client as BugBoardClient; public function __construct(private readonly BugBoardClient $bugboard) {} $this->bugboard->major('Checkout is slow', $exception, ['checkout']);
Plain PHP (any framework)
Build a client once and hold onto it — reports buffer during the request and are delivered by a
shutdown hook (or call flush() yourself):
use BugBoard\ClientBuilder; use BugBoard\Config; $bugboard = ClientBuilder::create(new Config( keyId: getenv('BUGBOARD_KEY_ID') ?: null, signingSecret: getenv('BUGBOARD_SIGNING_SECRET') ?: null, environment: 'production', )); $bugboard->minor('Tooltip misaligned', null, 'ui,polish'); $bugboard->flush(); // optional — the shutdown hook flushes automatically
ClientBuilder::create() accepts an explicit PSR-18 client + PSR-17 factories if you want to
control the HTTP stack; otherwise it uses Guzzle when installed and PSR discovery as the fallback.
If your config already lives in an array (from a config file, a container, .env parsing), skip the
Config constructor — ClientBuilder::createFromArray() takes snake_case or camelCase keys
and is exactly what the Laravel and Symfony integrations use internally:
$bugboard = ClientBuilder::createFromArray([ 'key_id' => getenv('BUGBOARD_KEY_ID') ?: null, 'signing_secret' => getenv('BUGBOARD_SIGNING_SECRET') ?: null, 'environment' => 'production', 'sample_rate' => 0.5, ]);
The 16 reporting methods
Every method takes (string $title, string|\Throwable|null $description = null, array|string $tags = []).
The method name sets the card's severity and priority — there is no generic report():
| low | medium (default) | high | |
|---|---|---|---|
| critical | criticalLow |
critical / criticalMedium |
criticalHigh |
| major | majorLow |
major / majorMedium |
majorHigh |
| moderate | moderateLow |
moderate / moderateMedium |
moderateHigh |
| minor | minorLow |
minor / minorMedium |
minorHigh |
Most apps only need the four medium-priority methods: critical, major, moderate, minor.
Tags accept an array (['ui', 'checkout']) or a CSV string ('ui,checkout').
$client->droppedCount() reports how many reports the buffer discarded (see maxQueueSize below) —
useful as a health metric if you sample heavily or report in tight loops.
Configuration
Every option, its type, and its default:
| Option | Type | Default | Purpose |
|---|---|---|---|
keyId |
string |
— | Public key id (bbk_…) for HMAC auth. Recommended for servers. |
signingSecret |
string |
— | Signing secret (bb_sec_…). Never transmitted. |
apiKey |
string |
— | Publishable key (bb_pub_…), bearer auth — a client-side key; rarely right in PHP. |
encryptionPublicKey |
string |
— | Base64 X25519 public key. When set, every payload is encrypted in transit. |
encryptionKeyId |
string |
— | bbek_… id echoed in the envelope (enables key rotation). |
enabled |
bool |
true |
Master switch (e.g. disable in tests). |
environment |
string |
— | Added to every card as tag env:<value>. |
release |
string |
— | Added to every card as tag release:<value>. |
defaultTags |
string[] |
[] |
Merged into every card's tags. |
sampleRate |
float |
1.0 |
Probability (0–1) a report is sent. |
maxQueueSize |
int |
100 |
Buffer cap; overflow drops the newest report. |
timeoutMs |
int |
5000 |
Per-request timeout. |
maxRetries |
int |
3 |
Retries for 429/5xx/network errors (backoff + jitter, honors Retry-After). |
beforeSend |
Closure |
— | Scrub PII or veto a report — return the payload array, or null to drop. |
debug |
bool |
false |
Verbose internal logging via error_log (keys always redacted). |
logLocally |
bool |
false |
Log each report locally instead of sending it (dry run). |
captureLocation |
bool |
true |
Auto-capture the caller's file/line as file_name/line_number. |
hideApiResponse |
bool |
true |
Ask the server to omit the card from its response (not echoed back). |
concurrency and flushIntervalMs are accepted but have no effect: PHP's execution model
delivers reports sequentially at flush time.
Scrubbing PII
new Config( keyId: …, signingSecret: …, beforeSend: function (array $payload): ?array { $payload['description'] = preg_replace('/\S+@\S+/', '[email]', $payload['description'] ?? '') ?: null; return $payload; // or return null to drop the report }, );
Encrypting sensitive reports
Set an encryption key and every payload is sealed with a libsodium sealed box (X25519) before it leaves the server — opaque at proxies and in access logs; BugBoard decrypts on receipt:
BUGBOARD_ENCRYPTION_PUBLIC_KEY=base64-x25519-public-key BUGBOARD_ENCRYPTION_KEY_ID=bbek_xxxxxxxx
Generate the keypair under Settings → API Keys → Payload encryption. No extra dependency is
needed — sodium ships with PHP.
Delivery semantics
- Never blocks, never throws. Reporting methods buffer and return; delivery happens after
the response (Laravel
terminating, orregister_shutdown_function). Failures surface viaerror_logwhendebugis on — never as exceptions in your app. - Retries on 429/5xx/network errors with exponential backoff + jitter, honoring
Retry-After. Other 4xx (bad key, invalid payload) are never retried. - Deduplication is server-side: a report whose title or description exactly matches an existing card increments its occurrence count instead of creating a duplicate — use stable, deterministic titles (no timestamps or ids in the title).
- Quota drops are silent by design: when the project's monthly quota is exhausted the server accepts and drops the report — logged in debug mode, never retried.
Exceptions
Delivery failures are caught inside the SDK and surfaced on the debug log — they are never thrown
into your app. The taxonomy in BugBoard\Exceptions exists so that a custom TransportInterface,
or your own logging around it, can tell the cases apart:
| Exception | Raised on | Extra |
|---|---|---|
BugBoardException |
base class for the four below | — |
AuthException |
401 / 403 — bad or revoked key | — |
ValidationException |
422 — the payload was rejected | $fieldErrors (array<string, list<string>>) |
RateLimitException |
429 — too many reports | $retryAfter (?int, seconds) |
ServerException |
5xx, network failure, timeout | — |
Testing
Disable reporting entirely with enabled: false, or keep the client live but print reports instead
of sending them with logLocally: true.
To assert on what your code reported, inject a fake transport — TransportInterface is a
single-method seam:
use BugBoard\Client; use BugBoard\Config; use BugBoard\Payload; use BugBoard\TransportInterface; $transport = new class implements TransportInterface { /** @var list<Payload> */ public array $sent = []; public function send(Payload $payload): void { $this->sent[] = $payload; } }; $bugboard = new Client(new Config(keyId: 'bbk_test', signingSecret: 'bb_sec_test'), $transport); $bugboard->critical('Payment failed'); $bugboard->flush(); // $transport->sent[0]->severity === 'critical'
Contributing
Bug reports and pull requests are welcome — see CONTRIBUTING.md. Please read our Code of Conduct and report security issues per SECURITY.md.
License
MIT © BugBoard