# Plain PHP

Use this guide for vanilla PHP, Slim, CodeIgniter, CakePHP, WordPress, CLI tools — anything
without a dedicated integration. If you are on **[Laravel](/docs/frameworks/laravel)** or
**[Symfony](/docs/frameworks/symfony)**, start there instead.

## 1. Install

```bash
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](/docs/security/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](#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:

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

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

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

```php
require __DIR__ . '/src/bugboard.php';

try {
    $orders->capture($payment);
} catch (\Throwable $e) {
    bugboard()->criticalHigh('Payment capture failed', $e, ['payments', 'checkout']);
}
```

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

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

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

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

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

```php
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](/docs/security/encryption#enabling-phps-sodium-extension)**.

## Next steps

- **[PHP SDK](/docs/sdks/php)** — the base guide and the full method surface.
- **[Laravel](/docs/frameworks/laravel)** · **[Symfony](/docs/frameworks/symfony)** — the
  first-class integrations.
- **[API Reference](/docs/api-reference)** — every config option and the HTTP contract.
