BugBoardDocs

Laravel

Laravel is the best-supported integration: the package is auto-discovered, reports are delivered after the response has been sent, and everything is env-driven out of the box.

Tested against Laravel 11 and 12. The package declares no illuminate/* constraint, so it installs on older versions too — but only 11 and 12 are covered by its test suite.

1. Install

composer require bugboard/sdk

Laravel projects already ship Guzzle, so there is no HTTP client to choose. If yours doesn't, add one — see the PSR-18 note.

2. Add your credentials

A server uses a secret key — a key id plus a signing secret. Every request is HMAC-signed and the secret never travels on the wire:

BUGBOARD_KEY_ID=bbk_xxxxxxxx
BUGBOARD_SIGNING_SECRET=bb_sec_xxxxxxxx

Get them from your project under Settings → API Keys (create a Secret key).

That's the whole setup. The package is auto-discovered via its extra.laravel block — the service provider binds a shared Client singleton, aliases it as bugboard, and defaults environment to your APP_ENV, so cards are tagged with the environment automatically.

Disabled package discovery? If you list bugboard/sdk under extra.laravel.dont-discover, register the provider yourself in bootstrap/providers.php:

return [
    App\Providers\AppServiceProvider::class,
    BugBoard\Laravel\BugBoardServiceProvider::class,
];

3. Report

Three equivalent styles. Pick one and be consistent.

Facade — the most common:

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']);

Injection — better for testability, because you can swap the binding:

use BugBoard\Client as BugBoardClient;

public function store(Request $request, BugBoardClient $bugboard)
{
    $bugboard->moderate('Slow image upload', null, 'uploads');
}

Container alias — for code where neither fits:

app('bugboard')->minor('Tooltip misaligned');

Every method takes (string $title, string|\Throwable|null $description = null, array|string $tags = []). See the 16 reporting methods.

Delivery: the terminating phase

This is the part that matters, and the reason Laravel needs no flushing ceremony.

The service provider registers an $app->terminating() callback, so buffered reports are delivered after the response has been sent to the browser. Reporting adds zero latency to the request. The callback is guarded by $app->resolved(Client::class), so a request that never touched BugBoard does no work at all in the terminating phase.

Two consequences worth knowing:

  • Queued jobs and Artisan commands also terminate, so reports from them are delivered the same way. A queue:work process terminates the application after each job, so per-job flushing is already handled — you do not need the manual flush() loop that plain PHP workers require.
  • Under Octane the container persists across requests, so the singleton and its buffer are reused. Terminating callbacks still run per request, so reports still go out per request. If you ever see a report arriving a request late, call BugBoard::flush() explicitly at the end of the operation.

Configuration

Everything is env-driven. Publish the config file only when you need something env cannot express:

php artisan vendor:publish --tag=bugboard-config

Every key in the published config/bugboard.php reads from an env var:

BUGBOARD_ENABLED=true
BUGBOARD_ENVIRONMENT="${APP_ENV}"
BUGBOARD_RELEASE=1.4.2
BUGBOARD_DEFAULT_TAGS=api,backend
BUGBOARD_SAMPLE_RATE=1.0
BUGBOARD_MAX_QUEUE_SIZE=100
BUGBOARD_TIMEOUT_MS=5000
BUGBOARD_MAX_RETRIES=3
BUGBOARD_CAPTURE_LOCATION=true
BUGBOARD_DEBUG=false
BUGBOARD_LOG_LOCALLY=false
BUGBOARD_HIDE_API_RESPONSE=true

The full option table lives in the API Reference.

Scrubbing PII with before_send

before_send is the one option env cannot express — it is a closure, so it goes in the published config file directly. It receives the payload about to be sent and returns it (mutated or not), or null to drop the report:

// config/bugboard.php
'before_send' => function (array $payload): ?array {
    // Scrub emails out of descriptions
    $payload['description'] = preg_replace(
        '/[\w.+-]+@[\w-]+\.[\w.]+/',
        '[email]',
        $payload['description'] ?? ''
    ) ?: null;

    // Drop health-check noise entirely
    if (str_contains($payload['title'], 'HealthCheck')) {
        return null;
    }

    return $payload;
},

The returned array is re-validated, so the hook cannot produce an invalid request: an unknown severity falls back to moderate, an unknown priority to medium, and every length clamp is re-applied. Keep the closure fast and total — it runs synchronously inside the reporting call. If it throws, the report is lost and the error goes to the debug log; your app is unaffected.

config:cache cannot serialize a closure, and will fail loudly if before_send is in a cached config file. If you deploy with config caching — you should — bind a customized client in a service provider instead:

// app/Providers/AppServiceProvider.php
use BugBoard\Client;
use BugBoard\ClientBuilder;

public function register(): void
{
    $this->app->singleton(Client::class, fn ($app) => ClientBuilder::createFromArray([
        ...$app['config']->get('bugboard'),
        'before_send' => fn (array $p): ?array => $this->scrub($p),
    ]));
}

The package provider registers its singleton in register() and yours runs after it in bootstrap/providers.php, so your binding wins. The terminating flush still applies — it resolves whatever is bound to Client::class.

Reporting every unhandled exception

Laravel 11/12, in bootstrap/app.php:

use BugBoard\Laravel\Facades\BugBoard;
use Illuminate\Foundation\Configuration\Exceptions;

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->report(function (\Throwable $e) {
        BugBoard::critical($e->getMessage() ?: $e::class, $e);
    });
})

Laravel 10, in app/Exceptions/Handler.php:

public function register(): void
{
    $this->reportable(function (\Throwable $e) {
        BugBoard::critical($e->getMessage() ?: $e::class, $e);
    });
}

$e->getMessage() ?: $e::class matters — an exception with an empty message would otherwise produce a card with an empty title.

Filter before you ship this. A public app throws NotFoundHttpException and ValidationException constantly; those will burn your quota and bury real bugs:

$exceptions->report(function (\Throwable $e) {
    $ignore = [
        \Illuminate\Auth\AuthenticationException::class,
        \Illuminate\Validation\ValidationException::class,
        \Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class,
        \Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException::class,
    ];

    foreach ($ignore as $class) {
        if ($e instanceof $class) {
            return;
        }
    }

    // Severity from the class: infrastructure failures outrank the rest
    $method = match (true) {
        $e instanceof \PDOException,
        $e instanceof \Illuminate\Database\QueryException => 'criticalHigh',
        $e instanceof \Illuminate\Http\Client\ConnectionException => 'major',
        default => 'moderate',
    };

    BugBoard::{$method}($e->getMessage() ?: $e::class, $e, ['unhandled']);
});

Remember the dedup rule: a QueryException message includes the SQL and its bound values, which vary per request and will create a new card each time. For noisy exception types, prefer a stable synthetic title and let the description carry the detail:

BugBoard::criticalHigh('Database query failed: ' . $e::class, $e, ['database']);

Failed queue jobs

// app/Providers/AppServiceProvider.php
use BugBoard\Laravel\Facades\BugBoard;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Queue;

public function boot(): void
{
    Queue::failing(function (JobFailed $event) {
        BugBoard::major(
            'Queue job failed: ' . $event->job->resolveName(),
            $event->exception,
            ['queue', $event->connectionName],
        );
    });
}

resolveName() returns the job class, which is stable — so repeated failures of the same job deduplicate into one card with a rising occurrence count. That is exactly what you want.

Scheduled tasks

// routes/console.php
Schedule::command('reports:generate')
    ->daily()
    ->onFailure(function () {
        BugBoard::major('Scheduled task failed: reports:generate', null, ['scheduler']);
    });

Testing

Disable it in the test environment:

# .env.testing
BUGBOARD_ENABLED=false

A client with no credentials is already inert, so a test environment with no keys reports nothing by default. Being explicit is still better — it documents the intent and survives someone adding keys to CI.

To see what you would have reported without sending anything, keep the client live and turn on BUGBOARD_LOG_LOCALLY=true. Reports are pretty-printed to the log instead of transmitted, which exercises real config resolution, payload building, and before_send.

To assert on reports in a test, bind a fake transport — TransportInterface is a single-method seam:

use BugBoard\Client;
use BugBoard\Config;
use BugBoard\Payload;
use BugBoard\TransportInterface;

final class FakeTransport implements TransportInterface
{
    /** @var list<Payload> */
    public array $sent = [];

    public function send(Payload $payload): void
    {
        $this->sent[] = $payload;
    }
}
$transport = new FakeTransport();

$this->app->singleton(Client::class, fn () => new Client(
    new Config(keyId: 'bbk_test', signingSecret: 'bb_sec_test'),
    $transport,
));

(new CheckoutService(app(Client::class)))->charge($failingOrder);

app(Client::class)->flush(); // reports are buffered until flush

expect($transport->sent)->toHaveCount(1);
expect($transport->sent[0]->severity)->toBe('critical');

Don't forget the flush(). Reports sit in the buffer until then; without it $sent is empty and the test fails confusingly.

Troubleshooting

Turn on BUGBOARD_DEBUG=true first — output goes to error_log, which under Laravel's default setup is storage/logs/laravel.log. Keys are always redacted, so debug output is safe to paste into an issue.

  • Nothing arrives. Check, in order: no credentials (keyId alone isn't enough — HMAC needs both), enabled resolving to false (a stale config:cache is the classic — php artisan config:clear), sampled out, dropped by before_send, queue full, quota exhausted, or a 401/403 in the debug log.
  • Cards multiply instead of counting up. Your titles carry interpolated ids, timestamps or SQL. Move the variable part into the description.
  • Unrelated errors collapse into one card. Titles are too generic — 'Request failed' from four call sites is one card. Add the operation or subsystem, keeping it deterministic.
  • BadMethodCallException: Call to undefined method. A typo in a reporting method name. This is the one case where the client deliberately throws, because it is a programming error found at the first call. Check the casing: criticalHigh, not criticalhigh.

Next steps