bezhansalleh/filament-exceptions
A Filament plugin that logs Laravel exceptions to your database and adds a searchable admin panel to browse, filter, and inspect them, with stack traces, request context, and user details, so you can debug production issues without digging through log files.
Install and wire it up (5 minutes).
composer require bezhansalleh/filament-exceptions
php artisan vendor:publish --tag="filament-exceptions-migrations"
php artisan vendor:publish --tag="filament-exceptions-config"
php artisan migrate
Register the plugin on the panel that should host the viewer — not necessarily every panel:
// app/Providers/Filament/AdminPanelProvider.php
use BezhanSalleh\FilamentExceptions\FilamentExceptionsPlugin;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->plugin(FilamentExceptionsPlugin::make());
}
Capture hooks into Laravel's exception reporting automatically, so there is nothing to add to your exception handler. Confirm that assumption early if your app overrides report(), uses a custom handler, or has a populated $dontReport array — anything suppressed there never reaches the table.
Where to look first, in this order:
config/filament-exceptions.php — read it top to bottom before writing any code. Navigation group/sort, the model class, and the resource class are the extension points you'll touch most; key names shift between minors, so trust the file on disk over any blog post.headers, request, body, resolved or similar) and their types. Every customization below depends on knowing these exactly.vendor/bezhansalleh/filament-exceptions/src/Resources/ExceptionResource.php — the list/filter/action shape you'll be extending.First use case — close the loop end to end. Hit a throwing route, then walk the full triage cycle once:
Route::get('/boom', fn () => throw new RuntimeException('Testing exception viewer'));
Open the new navigation item, confirm the badge shows 1, open the record, read the stack trace and request context, click resolve, confirm the badge drops to 0. That's the entire product. Everything else is customization or operational hardening.
The daily loop: badge → unresolved filter → resolve after deploy. Treat the navigation badge as the team's "does anyone need to look at this?" signal and the unresolved filter as the work queue. The workflow that actually holds up: after shipping a fix, filter unresolved, confirm the error has stopped recurring (watch the occurrence count on grouped duplicates), then bulk-resolve. Resolving is a manual claim that code changed — it is not a health check, and nothing will un-resolve a row if the bug comes back. It just reappears.
Gate it with a policy — on list and view. Exception rows routinely contain tokens, emails, cookies, and internal paths. Filament falls back to the model policy for viewAny/view, but policy auto-discovery won't find a policy for a vendor model, so register it explicitly:
// app/Policies/ExceptionPolicy.php
use App\Models\User;
use BezhanSalleh\FilamentExceptions\Models\Exception as ExceptionRecord;
class ExceptionPolicy
{
public function viewAny(User $user): bool { return $user->hasRole('super_admin'); }
public function view(User $user, ExceptionRecord $record): bool { return $user->hasRole('super_admin'); }
public function update(User $user, ExceptionRecord $record): bool { return $user->hasRole('super_admin'); }
}
// AppServiceProvider::boot()
Gate::policy(ExceptionRecord::class, ExceptionPolicy::class);
update matters: the resolve action is an update, so gating it hides the button. Log in as a non-admin and verify both the list page and a direct detail URL are blocked — a gate that only hides the nav item is not a gate.
Redact on write, not at read time. Once a secret is in the table it's in your backups too. Scrub at the model event:
ExceptionRecord::creating(function (ExceptionRecord $record) {
$redact = ['authorization', 'cookie', 'password', 'password_confirmation', 'token', 'secret'];
foreach (['headers', 'request'] as $attribute) { // confirm names against the migration
$payload = $record->getAttribute($attribute);
if (is_array($payload)) {
$record->setAttribute($attribute, collect($payload)
->map(fn ($value, $key) => in_array(strtolower((string) $key), $redact, true) ? '[redacted]' : $value)
->all());
}
}
});
Own retention. Assume pruning is yours to schedule — verify whether your version ships a prune command, but write the command either way so the ownership is explicit and alerting is on your schedule:
protected $signature = 'exceptions:prune {--days=30}';
public function handle(): int
{
$deleted = ExceptionRecord::query()
->where('resolved', true) // confirm column name
->where('created_at', '<', now()->subDays((int) $this->option('days')))
->delete();
$this->info("Pruned {$deleted} exception records.");
return self::SUCCESS;
}
Schedule it, and alert if it stops running — a silently failing prune is how the table becomes a database problem.
Build the digest the package doesn't. This is a viewer, not an APM; nothing pages anyone. A scheduled query plus a mail/Slack notification buys you most of the alerting value for ~15 lines:
$new = ExceptionRecord::query()
->where('resolved', false)
->where('created_at', '>=', now()->subHour())
->count();
Tenant scoping, if you run multi-tenant panels. Exceptions aren't tenant-tagged. Add the column, stamp it from filament()->getTenant() in a creating hook (guard for console/queue contexts where there is no tenant), then scope the query in a resource subclass and point the config's resource key at it:
class TenantExceptionResource extends ExceptionResource
{
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->where('tenant_id', filament()->getTenant()?->getKey());
}
}
Query the table like any Eloquent model. The biggest underrated win: exception records are rows, so "top error types this week", "errors by authenticated user", and "which release introduced this" are plain queries in Tinker or a custom widget. Pair a created_at pivot with deploy timestamps and you get rudimentary release correlation without any APM.
$dontReport and custom handler paths silently drop records. If a class of error never appears, check the handler before blaming the plugin.getNavigationBadgeColor() on the resource, re-verify after 4.2.1 — badge color resolution now delegates to the plugin. Your override should still win, but this was the one behavioral change in the release and it's exactly the kind of thing that silently stops applying.phpunit.xml and in your local .env.How can I help you explore Laravel packages today?