DEV Community

Cover image for Before You Blame Filament, Profile the Screen
Saqueib Ansari
Saqueib Ansari

Posted on • Originally published at qcode.in

Before You Blame Filament, Profile the Screen

If a Filament admin screen feels slow, the framework is usually not the first thing you should blame. That instinct is understandable because Filament sits at the visible layer. But in real Laravel apps, the slowdown usually comes from query shape, relation loading, policy checks, table configuration, and repeated per-row work. Filament just makes those mistakes more obvious because it gives you powerful abstractions and a lot of interactive surface area.

That is exactly why Filament performance testing matters. Without profiling, teams jump straight to framework-level fixes, Livewire paranoia, or cargo-cult caching. The result is wasted time and a slower codebase that becomes harder to reason about. The better approach is narrower and more boring: profile the specific screen, identify the hot path, and fix the actual bottleneck.

This article is opinionated on purpose. Do not optimize Filament globally. Optimize one screen at a time. If you cannot point to the exact query, callback, policy, widget, or relation manager causing the slowdown, you are not doing performance work yet. You are guessing.

Start With a Single Screen, Not a General Complaint

Most admin performance discussions start too vaguely.

Someone says the panel is slow. Another person blames Livewire. Someone else suggests Redis, queueing, Octane, or server upgrades. All of that is premature if the actual problem is a resource table firing 120 queries because three columns were made searchable across relations.

Filament screens are not uniform. A dashboard widget page, a resource index, an edit form, a relation manager, and a global search interaction each fail differently. If you test them as one system, the signal gets muddy fast.

The first rule is to isolate the problem into a page-level benchmark. Pick one slow screen and answer these questions:

  • Is the problem on initial load, or only after filters/search/sort?
  • Is the page slow for every user, or only users with more permissions or larger datasets?
  • Is the time spent in SQL, authorization, rendering, or repeated component work?
  • Does the slowdown scale with row count, relation depth, or visible widgets?

That sounds basic, but teams skip it constantly.

A practical profiling pass should test these screens separately:

  • resource index pages
  • edit pages with relation managers
  • dashboard pages with widgets and stats
  • modal actions and bulk actions
  • global search results

If one page is slow, do not treat the whole panel as slow. Filament is a container for many performance profiles, not one performance profile.

Build a Simple Local Baseline

You do not need a perfect observability stack to start. You need consistent numbers.

In a Laravel app, the fastest baseline usually comes from Laravel Debugbar, Telescope, query logs, and request timing around the specific Livewire interaction. For local work, that is enough to get to the truth quickly.

A lightweight query/timing baseline can look like this:

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

DB::flushQueryLog();
DB::enableQueryLog();

$startedAt = microtime(true);

app()->terminating(function () use ($startedAt) {
    $queries = DB::getQueryLog();

    Log::info('filament-screen-profile', [
        'duration_ms' => round((microtime(true) - $startedAt) * 1000, 2),
        'query_count' => count($queries),
        'slowest_queries' => collect($queries)
            ->sortByDesc('time')
            ->take(10)
            ->values()
            ->all(),
    ]);
});
Enter fullscreen mode Exit fullscreen mode

That code is intentionally unglamorous. Good. Performance work should start with facts, not tooling theatre.

When you profile a Filament screen, record these values every time:

  • total request duration
  • total query count
  • slowest queries by time
  • peak memory if the page loads large collections
  • row count displayed
  • which interaction triggered the work

That last point is easy to miss. A Filament page may be acceptable on first render and terrible when sorting by a related column, opening a modal, switching tabs, or loading a relation manager. If you only test the first page hit, you will miss the expensive path that users actually feel.

Most "Filament Is Slow" Problems Are Really Query Problems

The most common cause of a slow Filament page is not rendering. It is bad data loading strategy.

Filament makes it easy to define expressive tables:

  • relationship-backed columns
  • counts and badges
  • computed text
  • searchable related fields
  • sortable related fields
  • tab counts
  • conditional state based on related models

Every one of those features can be fine. The problem is when they stack without an explicit query strategy.

Here is a pattern that looks clean and often performs badly:

public static function table(Table $table): Table
{
    return $table
        ->columns([
            TextColumn::make('name')->searchable(),
            TextColumn::make('company.name')->searchable()->sortable(),
            TextColumn::make('roles.name')->badge(),
            TextColumn::make('projects_count')->counts('projects'),
            TextColumn::make('latestInvoice.status')->badge(),
            TextColumn::make('owner.email')->toggleable(),
        ]);
}
Enter fullscreen mode Exit fullscreen mode

Nothing here is obviously wrong. That is why teams get trapped.

The hidden problem is that this table definition is also a data access definition. Search across company.name can force joins or subqueries. Rendering roles.name can touch collections that were not eager loaded properly. Sorting on related state can generate ugly SQL. Showing latestInvoice.status may trigger relation access patterns you did not think about.

The fix is not to stop using Filament features. The fix is to make the page query explicit.

Define a Screen-Specific Query Contract

A strong default is to override the resource query and load only what the screen actually needs.

use Illuminate\Database\Eloquent\Builder;

public static function getEloquentQuery(): Builder
{
    return parent::getEloquentQuery()
        ->select([
            'id',
            'name',
            'company_id',
            'owner_id',
            'latest_invoice_id',
            'status',
            'created_at',
        ])
        ->with([
            'company:id,name',
            'owner:id,email',
            'latestInvoice:id,customer_id,status,due_at',
            'roles:id,name',
        ])
        ->withCount('projects');
}
Enter fullscreen mode Exit fullscreen mode

This is not exciting code, but it is the kind of code that makes admin panels fast.

A few practical rules matter here:

  • Select only the columns the page needs. Pulling full row payloads for every model in an admin table is lazy and expensive.
  • Narrow eager loads aggressively. with('company') is acceptable when prototyping, not when debugging a slow production screen.
  • Use withCount(), withExists(), and constrained eager loads deliberately. Let SQL do the aggregation once instead of letting PHP rediscover it row by row.
  • Avoid hidden relation access in accessors. Accessors that look elegant in models can destroy table performance because they hide expensive reads behind attribute access.

Run EXPLAIN Before You Touch Caching

If one query dominates the timeline, inspect the query plan before you add caching or try to "optimize Filament." In a lot of cases, the table is simply revealing schema weaknesses.

Common problems include:

  • missing indexes on foreign keys used in filters
  • missing indexes on status or date columns used in sorting
  • relationship search hitting large unindexed text fields
  • composite queries that need multi-column indexes but only have single-column indexes

Filament does not create those problems. It just makes them visible because admin tables are query-heavy by nature.

If a slow screen depends on filtering orders by team_id, status, and created_at, and you only indexed team_id, the panel will feel slow no matter how clean the Filament configuration is.

This is why performance testing should stay grounded in database behavior. If the SQL is bad, the UI framework is not the bottleneck.

Table Configuration Can Create Death by a Thousand Cuts

Even when the main query is fine, Filament tables can still feel slow because of repeated per-row work. This is where performance slips from "one obvious bad query" into a more annoying pattern: lots of individually reasonable decisions that add up badly.

Per-row formatting, badge color callbacks, visibility rules, icon logic, and custom state derivation all execute inside the rendering loop. On a 25-row page, maybe that is harmless. On a 100-row page with multiple relation-backed columns, it becomes expensive quickly.

Watch for Queries Inside Column Logic

This pattern is common and usually wrong:

TextColumn::make('status')
    ->badge()
    ->formatStateUsing(function ($state, $record) {
        return $record->latestInvoice?->is_overdue
            ? 'Overdue'
            : ucfirst($state);
    })
    ->color(fn ($record) => $record->orders()
        ->where('priority', 'high')
        ->exists()
            ? 'danger'
            : 'gray');
Enter fullscreen mode Exit fullscreen mode

That exists() call looks tiny. It is not tiny when it runs once per row.

This is the kind of mistake that gets blamed on Livewire diffing or framework overhead when the real issue is simple: you embedded a query decision inside presentation code.

A better pattern is to push the expensive logic into the base query:

public static function getEloquentQuery(): Builder
{
    return parent::getEloquentQuery()
        ->with([
            'latestInvoice:id,customer_id,is_overdue',
        ])
        ->withExists([
            'orders as has_high_priority_orders' => fn ($query) =>
                $query->where('priority', 'high'),
        ]);
}
Enter fullscreen mode Exit fullscreen mode

Then keep the table definition dumb:

TextColumn::make('status')
    ->badge()
    ->formatStateUsing(fn ($state, $record) =>
        $record->latestInvoice?->is_overdue ? 'Overdue' : ucfirst($state)
    )
    ->color(fn ($record) => $record->has_high_priority_orders ? 'danger' : 'gray');
Enter fullscreen mode Exit fullscreen mode

That principle scales well: precompute expensive truth once, render it many times cheaply.

Be Skeptical of Overly Smart Columns

Filament lets you build rich tables quickly. That does not mean every nice-looking column is worth the cost.

These features deserve scrutiny when a screen is slow:

  • searchable relationship columns
  • sortable computed columns
  • badges based on multiple relations
  • icon and color callbacks that inspect extra state
  • columns driven by accessors with hidden query reads
  • wide tables with many toggleable but still computed columns

A recurring production mistake is adding convenience columns that answer interesting questions but are not essential to the first screen. That is a product problem masquerading as a technical problem.

If a column requires complex relation loading and expensive conditional logic, ask a harder question: does this belong on the list page at all, or should it live on the detail page?

Admin UX is not improved by making every row a mini analytics dashboard.

Search and Sort Need Restraint

Search and sort are especially dangerous because they look like low-risk improvements. They are not.

A broad ->searchable() on multiple relationship-backed columns can turn a fast screen into a heavy query generator. Sorting by related state can make the query planner miserable. Searching computed text is often a smell unless that value is persisted or denormalized intentionally.

For most real admin panels, a better pattern is:

  • search a small set of indexed primary fields
  • use explicit filters for status, team, owner, or date ranges
  • denormalize a display field when you know the list screen depends on it
  • avoid pretending every interesting value should be globally searchable

That approach is less magical and more reliable.

Authorization Often Hurts More Than Teams Expect

Laravel developers usually remember to profile SQL. They often forget to profile policies, gates, and visibility checks.

Filament calls authorization in a lot of places:

  • page access
  • navigation visibility
  • row actions
  • bulk actions
  • form field visibility
  • relation manager visibility
  • action enable/disable state

If your policy logic performs relationship queries or repeated membership checks, it can dominate a request even when the main table query is reasonable.

The Common Multi-Tenant Trap

Here is a policy shape that is logically fine but dangerous on a busy Filament screen:

public function update(User $user, Project $project): bool
{
    return $user->teams()
        ->whereKey($project->team_id)
        ->wherePivot('role', 'admin')
        ->exists();
}
Enter fullscreen mode Exit fullscreen mode

If that policy is evaluated repeatedly for row actions across a table, the cost compounds quickly. The issue is not that policies are bad. The issue is that per-record authorization that re-queries shared context is wasteful.

Better options include:

  • preload the current tenant membership context once per request
  • memoize cheap authorization state in the request lifecycle
  • avoid duplicating the same expensive logic in visible(), disabled(), and policy checks together
  • centralize team or tenant role resolution so it does not get rediscovered per row

That does not mean you should bypass policies. It means you should make them cheap.

Profile Authorization Like You Profile Queries

A good local test is brutally simple: temporarily stub or simplify the suspicious policy path and compare request time. If the screen suddenly becomes fast, you found a policy bottleneck.

Another useful tactic is request-scoped memoization for shared checks:

class TeamMembershipService
{
    protected array $cache = [];

    public function isTeamAdmin(int $userId, int $teamId): bool
    {
        $key = "{$userId}:{$teamId}";

        return $this->cache[$key] ??= \DB::table('team_user')
            ->where('user_id', $userId)
            ->where('team_id', $teamId)
            ->where('role', 'admin')
            ->exists();
    }
}
Enter fullscreen mode Exit fullscreen mode

Used carefully, that kind of request-local caching is a real fix because it eliminates redundant work without introducing stale cross-request state.

The larger point is this: authorization logic is part of performance architecture. In admin panels, it is not just a security concern.

Relation Managers, Widgets, and Tabs Multiply Hidden Work

When teams say a Filament edit page feels heavy, the form itself is often not the issue. The actual cost lives in the surrounding page chrome: relation managers, widgets, stat cards, tab badges, and counts sprinkled around the interface.

This is the second big source of framework blame. The main page appears slow, so Filament gets accused. But the page is really acting like a compound dashboard.

Relation Managers Are Easy to Underestimate

Each relation manager is its own data loader, table, action surface, and policy consumer. Add three of them to an edit page and the request lifecycle gets crowded fast.

The problem gets worse when relation managers are visible by default even though the user only needs one at a time. If each manager performs counts, eager loads, and action authorization on first render, the page can feel sluggish before the user touches anything.

When profiling an edit page, test with relation managers disabled one by one. That quickly reveals whether the form is slow or the surrounding components are slow.

Widgets and Badge Counts Are Frequent Offenders

Dashboards and resource pages love metrics. Counts feel cheap. They are often not.

A common anti-pattern looks like this:

  • a widget counts overdue invoices
  • a tab badge counts overdue invoices again
  • a header stat computes the same number with slightly different constraints
  • a relation manager tab recomputes another near-identical count

The UI looks polished. The backend is doing duplicate work.

If a metric appears in multiple places, centralize how it is loaded. Either compute it once in a dedicated query path or decide that not every surface needs a live count.

This is where product discipline matters. Not every badge is worth a query.

Pagination Is Usually Better Than Table Maximalism

Another quiet performance killer is oversized tables. Teams often assume that showing 100 rows by default is better admin UX than showing 25. In practice, that is usually wrong.

A smaller, faster page with sharp filters is better than a large, sluggish page filled with decorative columns and computed state.

If users complain they need more context per page, the first move should not be increasing pagination blindly. Try this order instead:

  1. remove low-value columns
  2. move expensive detail into the record page
  3. simplify row-level visual logic
  4. add better filters or presets
  5. only then consider raising rows per page

That sequence preserves speed and keeps the table useful.

A Production-Sane Workflow for Filament Performance Testing

Once you stop guessing, the work becomes much more straightforward. Most slow Filament screens can be improved with a disciplined pass through the same layers.

The Order Actually Matters

This is the sequence I would recommend for most Laravel teams:

  1. profile one screen and capture request time plus query count
  2. identify the slowest queries and run EXPLAIN
  3. make the resource query explicit with select(), narrow with(), withCount(), and withExists()
  4. remove query work from column callbacks and per-row presentation logic
  5. inspect policies, action visibility rules, and repeated authorization checks
  6. isolate widgets, relation managers, tab badges, and duplicated metrics
  7. only after that, consider caching, Octane, or deeper infrastructure changes

That order saves time because it forces you to work from the hottest bottleneck outward.

A Realistic Refactor Pattern

Suppose a Filament resource index is slow because it shows customer data, invoice state, and order priority all in one table. A sensible refactor would usually look like this:

  • restrict the base select list
  • eager load only the exact relationships needed for visible columns
  • convert per-row exists() checks into withExists()
  • replace free-form relationship search with targeted indexed filters
  • trim low-value columns from the default table state
  • lower the default page size if the screen is still doing too much

That is not glamorous work. It is effective work.

What Not to Do First

A few things are usually the wrong first move:

  • adding broad caching before understanding query shape
  • blaming Livewire for N+1 issues created in your table config
  • increasing server resources before fixing obvious SQL waste
  • collapsing everything into one giant index page because it feels convenient
  • treating accessors as free when they hide expensive relation reads

Those choices make the system harder to debug later.

The Practical Decision Rule

Filament is fast enough for serious Laravel applications. What it does not do is protect you from expensive Eloquent habits, sloppy authorization design, or overly ambitious admin tables.

That is not a weakness. It is just the reality of building rich internal tools on top of expressive abstractions.

If a screen is slow, your job is not to defend the framework or attack it. Your job is to locate the exact cost center. In most cases, you will find one of five things:

  • the query loads too much data
  • the table performs too much per-row work
  • the policy layer repeats shared checks
  • the page bundles too many widgets or relation managers
  • the schema is missing indexes for how the admin UI actually queries data

Official docs worth keeping nearby while profiling:

The rule of thumb is simple and worth repeating: do not blame Filament until you can name the exact query, callback, policy, widget, or tab that is slow. If you cannot name it, you have not tested performance yet. You have only felt it.


Read the full post on QCode: https://qcode.in/filament-performance-testing-before-you-blame-framework/

Top comments (0)