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/sdkunderextra.laravel.dont-discover, register the provider yourself inbootstrap/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:workprocess terminates the application after each job, so per-job flushing is already handled — you do not need the manualflush()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:cachecannot serialize a closure, and will fail loudly ifbefore_sendis 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 inbootstrap/providers.php, so your binding wins. The terminating flush still applies — it resolves whatever is bound toClient::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 (
keyIdalone isn't enough — HMAC needs both),enabledresolving to false (a staleconfig:cacheis the classic —php artisan config:clear), sampled out, dropped bybefore_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, notcriticalhigh.
Next steps
- PHP SDK — the base guide and the full method surface.
- Symfony · Plain PHP — the other PHP integrations.
- Encrypting sensitive reports — seal payloads in transit.
- API Reference — every config option and the HTTP contract.