bugboard / sdk
Official BugBoard SDK for PHP โ report bugs 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
- illuminate/contracts: ^11.0 || ^12.0
- laravel/pint: ^1.21
- orchestra/testbench: ^9.0 || ^10.0
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5
- psr/simple-cache: ^2.0 || ^3.0
- 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).
- psr/simple-cache: A PSR-16 cache for Psr16QuotaStore, so quota suppression survives between requests.
README
The official BugBoard SDK for PHP. Report bugs 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.
๐ Usage guide โ in-depth integration for plain PHP, Laravel, and Symfony: exception handlers, queue workers, Monolog, config caching, testing, and troubleshooting.
Contents
- Requirements
- Installation
- Installing php-sodium โ only for payload encryption
- Framework setup โ Laravel ยท Symfony ยท Plain PHP
- The 16 reporting methods
- Configuration โ every option, and scrubbing PII
- Encrypting sensitive reports
- Delivery semantics โ retries, dedup, quotas, exceptions
- Testing
- Contributing ยท License
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'); }
If you have disabled package discovery (
extra.laravel.dont-discoverin yourcomposer.json), register the provider yourself inbootstrap/providers.php:return [ App\Providers\AppServiceProvider::class, BugBoard\Laravel\BugBoardServiceProvider::class, ];
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, mixed $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').
The description accepts anything โ no json_encode() needed. A Throwable contributes its message
and trace, arrays and objects are pretty-printed as JSON, and Arrayable objects (a Laravel
Request, an Eloquent model) are unwrapped via toArray() first:
BugBoard::critical('Validation failed', $request); BugBoard::major('Bad cart state', ['user_id' => $user->id, 'items' => $cart->items]);
Mind what you attach: request input and model attributes routinely contain secrets. See What the description accepts.
$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 event allowance is exhausted โ or the project is paused or archived โ the server accepts and drops the report. Logged, never retried, never thrown into your app.
-
The SDK then stops sending: after a drop it discards reports locally instead of sending them, until the drop is expected to have cleared (the next midnight UTC for a spent allowance, 30 minutes for a paused or archived project). One report is let through afterwards to check, so reporting resumes on its own.
On Laravel and Symfony this is wired to your application cache automatically, so the suppression holds across requests. Standalone PHP-FPM users get it only within a single request unless they pass a
QuotaStoreโ see Quota suppression.
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