BugBoardDocs

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 keyId and signingSecret), never flushed (a long-running worker, or a process killed rather than exited), enabled false, sampled out, dropped by beforeSend, queue full, quota exhausted, or a 401/403.
  • Requests got slower. You are flushing on the request path — call fastcgi_finish_request() before flush().
  • sodium_crypto_box_seal undefined. ext-sodium isn't loaded in the runtime serving your app. php -m reports on the CLI, which is usually a different php.ini than FPM — check php-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.