# 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

```bash
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](/docs/frameworks/plain-php#about-the-http-client)**.

## 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**:

```dotenv
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`:
>
> ```php
> return [
>     App\Providers\AppServiceProvider::class,
>     BugBoard\Laravel\BugBoardServiceProvider::class,
> ];
> ```

## 3. Report

Three equivalent styles. Pick one and be consistent.

**Facade** — the most common:

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

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

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

Every method takes `(string $title, string|\Throwable|null $description = null, array|string $tags = [])`.
See **[the 16 reporting methods](/docs/sdks/php#common-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](/docs/frameworks/plain-php#cli-scripts-workers-and-daemons)** 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:

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

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

```dotenv
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](/docs/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:

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

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

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

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

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

## Failed queue jobs

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

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

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

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

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

- **[PHP SDK](/docs/sdks/php)** — the base guide and the full method surface.
- **[Symfony](/docs/frameworks/symfony)** · **[Plain PHP](/docs/frameworks/plain-php)** — the
  other PHP integrations.
- **[Encrypting sensitive reports](/docs/security/encryption)** — seal payloads in transit.
- **[API Reference](/docs/api-reference)** — every config option and the HTTP contract.
