Plain PHP
Use this guide for vanilla PHP, Slim, CodeIgniter, CakePHP, WordPress, CLI tools — anything without a dedicated integration. If you are on Laravel or Symfony, start there instead.
1. Install
composer require bugboard/sdk composer require guzzlehttp/guzzle
Requirements: PHP 8.2+, a PSR-18 HTTP client plus PSR-17 factories, and ext-sodium only
if you enable payload encryption.
About the HTTP client
The SDK deliberately does not pin an HTTP client. It requires the virtual packages
psr/http-client-implementation and psr/http-factory-implementation, so Composer accepts
whatever implementation your project already has.
Guzzle gets special treatment: when it is installed, ClientBuilder constructs it directly
rather than going through discovery, so your configured timeoutMs is applied to both the
connect and the total timeout. With any other PSR-18 client, discovery returns a client
configured however it defaults, and timeoutMs is not enforced. If you use Symfony's HTTP
client, a cURL client, or anything else and you care about the timeout, construct it yourself
and pass it in — see Full control over the HTTP stack.
2. Build one client and share it
The client is stateful — it holds the buffer — so build it once per process and pass it around. Building a client per report gives each one its own buffer and its own shutdown hook, which works but is wasteful:
// src/bugboard.php use BugBoard\ClientBuilder; use BugBoard\Config; function bugboard(): \BugBoard\Client { static $client = null; return $client ??= ClientBuilder::create(new Config( keyId: getenv('BUGBOARD_KEY_ID') ?: null, // bbk_… signingSecret: getenv('BUGBOARD_SIGNING_SECRET') ?: null, // bb_sec_… environment: getenv('APP_ENV') ?: 'production', release: getenv('APP_RELEASE') ?: null, )); }
BUGBOARD_KEY_ID=bbk_xxxxxxxx BUGBOARD_SIGNING_SECRET=bb_sec_xxxxxxxx
A container-based app should register it as a shared service rather than a static — the point is only that there is one instance.
Building from an array
If your configuration already lives in an array — a config file, container parameters, parsed
.env — skip the Config constructor. createFromArray() accepts snake_case or
camelCase keys and casts loosely-typed values, which is exactly what env values look like:
use BugBoard\ClientBuilder; $bugboard = ClientBuilder::createFromArray([ 'key_id' => getenv('BUGBOARD_KEY_ID') ?: null, 'signing_secret' => getenv('BUGBOARD_SIGNING_SECRET') ?: null, 'environment' => 'production', 'sample_rate' => '0.5', // string is fine, cast to float 'enabled' => 'true', // string is fine, cast to bool 'default_tags' => 'api,backend', // CSV string is fine, split to a list ]);
This is the same entry point the Laravel and Symfony integrations use internally. The one
option it cannot express is beforeSend, which is a Closure — pass a real closure or it is
ignored.
3. Report
require __DIR__ . '/src/bugboard.php'; try { $orders->capture($payment); } catch (\Throwable $e) { bugboard()->criticalHigh('Payment capture failed', $e, ['payments', 'checkout']); }
bugboard()->major('Checkout is slow'); // a title is all you need bugboard()->minor('Tooltip misaligned', null, 'ui,polish');
Delivery: where the shutdown hook fires
The first buffered report registers a register_shutdown_function hook, so flushing is
automatic. But in plain PHP that hook runs when the script ends — which under PHP-FPM is
before the response has been flushed to the client. Delivering reports therefore adds
latency to the request, and with maxRetries: 3 against a slow endpoint that latency has a
ceiling of several seconds.
Under PHP-FPM, hand the response to the user first:
// after you have echoed the response if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); } bugboard()->flush(); // now delivery costs the user nothing
fastcgi_finish_request() closes the connection and lets the worker continue. This is exactly
what Laravel's terminating phase does for you, and it is the main thing you give up by not
using a framework integration.
If you can't call it (mod_php, some SAPIs), the alternatives are to lower maxRetries and
timeoutMs so the worst case is bounded, or to accept the latency on what should be a rare
path.
CLI scripts, workers, and daemons
For a short script, the shutdown hook is enough — it fires when the script ends, and there is no response latency to worry about.
For a long-running worker, the shutdown hook only fires when the process finally exits, which could be days away. Flush at the end of each unit of work:
while ($job = $queue->pop()) { try { $job->handle(); } catch (\Throwable $e) { bugboard()->major('Job failed: ' . $job::class, $e, ['queue']); } finally { bugboard()->flush(); // deliver per job, not per process lifetime } }
Without this, a worker that reports faster than it exits will hit maxQueueSize (default 100)
and start dropping. bugboard()->droppedCount() tells you how many have been dropped and is
worth emitting as a health metric.
A global exception handler
To get every uncaught error onto your board:
set_exception_handler(function (\Throwable $e): void { bugboard()->critical($e->getMessage() ?: $e::class, $e); }); register_shutdown_function(function (): void { $error = error_get_last(); if ($error !== null && ($error['type'] & (E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR))) { bugboard()->criticalHigh($error['message'], sprintf('%s:%d', $error['file'], $error['line'])); bugboard()->flush(); // we are already in shutdown; flush explicitly } });
Note the explicit flush() in the fatal-error handler: the SDK's own shutdown hook may have
been registered before this one, in which case it has already run.
Use $e->getMessage() ?: $e::class rather than the message alone — an exception with an empty
message would otherwise produce a card with an empty title.
Full control over the HTTP stack
ClientBuilder::create() takes an optional PSR-18 client and PSR-17 factories. Pass them when
you need a proxy, custom TLS settings, connection pooling, or a non-Guzzle client with a real
timeout:
use BugBoard\ClientBuilder; use BugBoard\Config; use GuzzleHttp\Client as GuzzleClient; use GuzzleHttp\Psr7\HttpFactory; $factory = new HttpFactory(); $bugboard = ClientBuilder::create( new Config(keyId: '…', signingSecret: '…'), new GuzzleClient([ 'timeout' => 3, 'proxy' => 'http://proxy.internal:8080', 'http_errors' => false, // important: the SDK reads status codes itself ]), $factory, // RequestFactoryInterface $factory, // StreamFactoryInterface );
Set http_errors => false on Guzzle. The transport maps status codes to its own exception
taxonomy and needs to see the response, not a Guzzle exception.
Testing
Pass enabled: false to disable reporting, or logLocally: true to keep the client live and
print reports instead of sending them. To assert on what was reported, pass a fake transport
directly to Client — no HTTP, no builder:
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(); // reports are buffered until flush // $transport->sent[0]->severity === 'critical'
Troubleshooting
Turn on debug: true first — output goes to error_log. Keys are always redacted, so debug
output is safe to paste into an issue.
- Nothing arrives. No credentials (HMAC needs both
keyIdandsigningSecret), never flushed (a long-running worker, or a process killed rather than exited),enabledfalse, sampled out, dropped bybeforeSend, queue full, quota exhausted, or a 401/403. - Requests got slower. You are flushing on the request path — call
fastcgi_finish_request()beforeflush(). sodium_crypto_box_sealundefined.ext-sodiumisn't loaded in the runtime serving your app.php -mreports on the CLI, which is usually a differentphp.inithan FPM — checkphp-fpm -m | grep sodium, then restart FPM. Full walkthrough in Encryption.
Next steps
- PHP SDK — the base guide and the full method surface.
- Laravel · Symfony — the first-class integrations.
- API Reference — every config option and the HTTP contract.