Filament gives you a production admin panel in an afternoon, which is exactly why its performance problems sneak up on you. Nobody profiles the admin — it's internal, it's for staff, it feels instant on a dev machine with fifty seed rows. Then the users table hits a hundred thousand rows, a relationship column lazy-loads on every one of them, and the page that used to open instantly takes eight seconds and times out for the one admin who filters by company. The panel is a Laravel app underneath, so the fixes are ordinary Laravel fixes — you just have to be looking at it.
Table resources — the biggest performance trap
A Filament Resource renders a Livewire-powered table. Each column is a template expression; relationship columns lazy-load their relations on access:
Bad — fires N queries per page load
class UserResource extends Resource
{
public static function table(Table $table): Table
{
return $table->columns([
TextColumn::make('name'),
TextColumn::make('latestOrder.total'), // N+1
TextColumn::make('company.name'), // N+1
TextColumn::make('subscription.plan'), // N+1
]);
}
}Good — eager-load in getTableQuery
class UserResource extends Resource
{
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->with(['latestOrder', 'company', 'subscription']);
}
}
For complex tables with counts, use withCount() instead of computing in the view. Aggregate columns should be SQL-side whenever possible.
Widget caching
Filament widgets re-render on page load and on ->poll() interval. Uncached queries run every time:
class TotalRevenueWidget extends StatsOverviewWidget
{
protected static ?string $pollingInterval = '30s';
protected function getStats(): array
{
// Bad — hits DB every 30s
// $total = Order::where('paid', true)->sum('total');
// Good — cached
$total = Cache::remember('stats.total_revenue', 30, fn () =>
Order::where('paid', true)->sum('total')
);
return [
Stat::make('Revenue', '$' . number_format($total, 2)),
];
}
}Admin auth failure tracking
/admin is a high-value target for attackers. Track failed logins:
app/Providers/AppServiceProvider.php
use Illuminate\Auth\Events\Failed;
use Illuminate\Support\Facades\Event;
Event::listen(function (Failed $event) {
if (str_contains(request()->path(), 'admin')) {
logger()->warning('Admin login failure', [
'email' => $event->credentials['email'] ?? null,
'ip' => request()->ip(),
'ua' => request()->userAgent(),
]);
}
});Alert on failed-login rate spikes per IP. Combine with Laravel's built-in throttling and ideally IP-allowlisting at the load balancer.
What to track per resource
- List page load — should be < 400ms p95 for admin usability
- Row actions — edit / delete / bulk. Target < 300ms per Livewire update.
- Form submit — complex forms with many fields can be slow on save. Profile the Resource model's save() path.
- Bulk actions — users expect these to be reasonably fast. If bulk is slow (> 2s for 50 rows), queue them instead.
Filtering your APM to admin traffic
In most APMs, filter requests by URL prefix:
- /admin/* — panel page loads, form submits
- /livewire/update — table filters, actions, form field updates
- /admin/login — auth attempts
THE EASY WAY
Catch Filament bottlenecks with per-request tracing
NightOwl records every /admin request with full trace breakdown — see which Livewire update fired 340 DB queries, which table resource loaded 50 MB of eager-loaded relations. The request drilldown turns Filament slowness from guesswork into a one-line fix.
composer require nightowl/agent
php artisan nightowl:install