+1 (415) 599-8902

Building a Laravel Admin Panel with Filament 4: Resources, Actions, Policies and Performance

Most Laravel projects that reach production eventually need a second application: the back office. Support staff need to find an order, refund it and leave a note. Ops need to approve a payout. Finance need a CSV. Building that by hand is weeks of unglamorous CRUD, and it is usually the part of the budget clients resent paying for.

Filament is the answer most Laravel teams reach for, and Filament 4 (built on Livewire 3, Alpine and Tailwind CSS v4) made it faster and considerably more coherent than earlier versions. This tutorial builds a real admin panel — resources, relations, roles, actions and tests — on a Laravel 13 app, and then covers the parts people usually get wrong: authorisation, performance on large tables, and keeping the panel out of your public surface area.

What you need

  • A Laravel 13 app on PHP 8.3+
  • Node 20+ (Filament compiles its own CSS/JS through Vite)
  • Existing models to administer — we will use User, Order and OrderItem

Install the panel

composer require filament/filament
php artisan filament:install --panels

The installer asks for a panel ID; use admin. It generates app/Providers/Filament/AdminPanelProvider.php, which is where nearly all panel-level configuration lives:

public function panel(Panel $panel): Panel
{
    return $panel
        ->default()
        ->id('admin')
        ->path('admin')
        ->login()
        ->colors(['primary' => Color::Indigo])
        ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
        ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
        ->middleware([
            EncryptCookies::class,
            AddQueuedCookiesToResponse::class,
            StartSession::class,
            AuthenticateSession::class,
            ShareErrorsFromSession::class,
            VerifyCsrfToken::class,
            SubstituteBindings::class,
            DisableBladeIconComponents::class,
            DispatchServingFilamentEvent::class,
        ])
        ->authMiddleware([Authenticate::class]);
}

Create yourself a user and log in at /admin:

php artisan make:filament-user

Gate the panel before you do anything else

By default, any authenticated user can reach a panel. That is the single most common Filament security mistake in the wild. Implement FilamentUser on your User model:

use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;

class User extends Authenticatable implements FilamentUser
{
    public function canAccessPanel(Panel $panel): bool
    {
        return $this->is_staff && $this->hasVerifiedEmail();
    }
}

Write the test now, not later:

it('blocks customers from the admin panel', function () {
    $this->actingAs(User::factory()->create(['is_staff' => false]))
        ->get('/admin')
        ->assertForbidden();
});

Your first resource

A resource is Filament's unit of CRUD: a form schema, a table, and pages for list/create/edit/view.

php artisan make:filament-resource Order --generate --view

--generate inspects your migration columns and drafts form fields and table columns for you. Treat the output as a starting point — generated schemas always contain fields no human should edit, such as id, timestamps or denormalised totals.

In Filament 4, forms and infolists are both built on the unified schema package, so the shape of the code is consistent:

use Filament\Schemas\Schema;
use Filament\Forms\Components\{TextInput, Select, DatePicker, Textarea};
use Filament\Schemas\Components\Section;

public static function form(Schema $schema): Schema
{
    return $schema->components([
        Section::make('Order')
            ->columns(2)
            ->schema([
                Select::make('user_id')
                    ->relationship('user', 'email')
                    ->searchable()
                    ->preload()
                    ->required(),
                Select::make('status')
                    ->options(OrderStatus::class)
                    ->required(),
                TextInput::make('reference')
                    ->required()
                    ->maxLength(32)
                    ->unique(ignoreRecord: true),
                DatePicker::make('placed_at')->required(),
            ]),
        Section::make('Internal')
            ->schema([
                Textarea::make('notes')->rows(4)->columnSpanFull(),
            ])
            ->collapsed(),
    ]);
}

Two habits worth forming:

  • ->relationship() on selects, with searchable() and preload() used deliberately. preload() loads every option into the page; fine for 50 statuses, fatal for 200,000 users. Use searchable() alone for large relations.
  • Validation lives in the schema. Filament's rules are real Laravel validation rules, so unique(ignoreRecord: true), requiredIf() and custom rule() closures all behave as you expect. Do not rely on database constraints to produce user-facing errors.

The table

use Filament\Tables\Table;
use Filament\Tables\Columns\{TextColumn, IconColumn};
use Filament\Tables\Filters\{SelectFilter, Filter};

public static function table(Table $table): Table
{
    return $table
        ->columns([
            TextColumn::make('reference')->searchable()->copyable(),
            TextColumn::make('user.email')->searchable()->sortable(),
            TextColumn::make('status')->badge()->sortable(),
            TextColumn::make('total')->money('gbp')->sortable()->alignEnd(),
            IconColumn::make('is_flagged')->boolean()->toggleable(),
            TextColumn::make('placed_at')->dateTime('d M Y H:i')->sortable(),
        ])
        ->filters([
            SelectFilter::make('status')->options(OrderStatus::class)->multiple(),
            Filter::make('high_value')->query(fn ($query) => $query->where('total', '>=', 100000)),
        ])
        ->defaultSort('placed_at', 'desc')
        ->deferLoading()
        ->persistFiltersInSession();
}

Making big tables fast

Admin tables are the usual performance complaint, and the causes are predictable:

  1. N+1 queries from relation columns. TextColumn::make('user.email') triggers a query per row unless the relation is eager-loaded. Override the query:

    ->modifyQueryUsing(fn (Builder $query) => $query->with('user:id,email'))
    

    Run your panel locally with Model::shouldBeStrict() enabled so lazy loading throws instead of quietly costing 50 queries.

  2. Counting rows for pagination. On multi-million-row tables the count(*) behind the pager dominates. Use ->paginationMode(PaginationMode::Simple) (or simplePaginate in your query) to drop it.

  3. Unindexed sorts and searches. Every sortable() and searchable() column becomes an ORDER BY or LIKE against your database. Add the indexes, and prefer searchable(isIndividual: true) over a global search across ten columns.

  4. Livewire payload size. Filament 4 renders tables statically by default and only makes cells interactive where needed, which cuts payloads dramatically compared with v3 — but toggling every column to toggleable() and enabling every feature still costs. Enable what staff actually use.

Relations: order items inside the order

php artisan make:filament-relation-manager OrderResource items product.name

Register it on the resource:

public static function getRelations(): array
{
    return [RelationManagers\ItemsRelationManager::class];
}

For a small, always-edited-together relation such as line items, an inline repeater in the form can be a better fit than a relation manager:

use Filament\Forms\Components\Repeater;

Repeater::make('items')
    ->relationship()
    ->table([
        Repeater\TableColumn::make('Product'),
        Repeater\TableColumn::make('Qty'),
        Repeater\TableColumn::make('Unit price'),
    ])
    ->schema([
        Select::make('product_id')->relationship('product', 'name')->searchable()->required(),
        TextInput::make('quantity')->numeric()->minValue(1)->required(),
        TextInput::make('unit_price')->numeric()->prefix('£')->required(),
    ])
    ->defaultItems(1)
    ->reorderable(false);

Rule of thumb: repeater for a handful of child rows owned by the parent; relation manager when the child records have their own lifecycle, filters or pagination.

Actions are where the business value is

CRUD is table stakes. What makes a back office worth building is safe, audited, one-click operations. Filament actions give you a confirmation modal, a form, authorisation and a notification in a dozen lines:

use Filament\Actions\Action;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;

Action::make('refund')
    ->icon('heroicon-o-banknotes')
    ->color('danger')
    ->visible(fn (Order $record) => $record->isRefundable())
    ->authorize(fn (Order $record) => auth()->user()->can('refund', $record))
    ->requiresConfirmation()
    ->modalDescription('This refunds the customer immediately and cannot be undone.')
    ->schema([
        Textarea::make('reason')->required()->maxLength(500),
    ])
    ->action(function (Order $record, array $data) {
        app(RefundOrder::class)->handle($record, $data['reason'], auth()->user());

        Notification::make()
            ->title('Refund queued')
            ->success()
            ->send();
    });

Note what the closure does not contain: no payment gateway calls, no state machine logic. The action collects input and delegates to an invokable service or job you can unit test independently of Filament. Long-running work belongs on the queue; return immediately and notify the user via a database notification when it finishes.

Bulk actions follow the same pattern, but always chunk:

BulkAction::make('export')
    ->action(fn (Collection $records) => ExportOrders::dispatch($records->pluck('id'), auth()->id()))
    ->deselectRecordsAfterCompletion();

Passing 5,000 hydrated models to a synchronous closure is how admin panels time out.

Authorisation with policies

Filament reads your Laravel policies automatically. Create one and the panel respects it everywhere — navigation, list, edit, delete, and bulk actions:

php artisan make:policy OrderPolicy --model=Order
public function viewAny(User $user): bool
{
    return $user->hasPermission('orders.view');
}

public function refund(User $user, Order $order): bool
{
    return $user->hasPermission('orders.refund') && $order->isRefundable();
}

If you use spatie/laravel-permission, the standard filament-shield plugin generates policies and a role management UI from your resources. For simpler setups, a permission check on the user model plus per-resource policies is less machinery to maintain.

Two extra guards worth adding on any panel handling customer data:

  • Multi-factor authentication. Filament 4 ships first-party MFA (app authenticator plus email codes); enable it in the panel provider and require it for staff.
  • Tenant or team scoping. If your app is multi-tenant, use Filament's tenancy support rather than filtering in each resource, and pair it with a global scope on the models so a missed filter cannot leak data.

Testing the panel

Filament panels are Livewire components, so Pest and livewire() test them directly. These tests are quick and catch the schema mistakes that only surface in the browser:

use function Pest\Livewire\livewire;

it('lists only orders the user may see', function () {
    $staff = User::factory()->staff()->create();
    $visible = Order::factory()->count(3)->create();

    livewire(ListOrders::class)
        ->actingAs($staff)
        ->assertCanSeeTableRecords($visible);
});

it('validates the refund reason', function () {
    $order = Order::factory()->refundable()->create();

    livewire(ViewOrder::class, ['record' => $order->getKey()])
        ->actingAs(User::factory()->staff()->withPermission('orders.refund')->create())
        ->callAction('refund', data: ['reason' => ''])
        ->assertHasActionErrors(['reason' => 'required']);
});

Add one query-count assertion on your busiest table to stop N+1 regressions from creeping back in:

it('loads the orders table without N+1 queries', function () {
    Order::factory()->count(25)->create();

    DB::enableQueryLog();
    livewire(ListOrders::class)->actingAs(User::factory()->staff()->create());

    expect(DB::getQueryLog())->toHaveCount(lessThan(10));
});

Deployment notes

  • php artisan filament:optimize in your deploy script caches components and Blade icons; pair it with filament:optimize-clear on rollback.
  • Filament assets are published to public/; run php artisan filament:assets after every upgrade or the panel will load a stale bundle.
  • Serve the panel on the same app but restrict it at the edge where you can — an allowlist or SSO in front of /admin costs nothing and removes a whole class of risk.
  • If you run Octane, watch for state held in custom Filament pages; the same singleton rules apply as anywhere else in a long-lived worker.

When Filament is the wrong tool

It is worth saying: Filament is excellent for staff-facing CRUD and operational tooling. It is not a good fit for a customer-facing product UI, for a heavily bespoke dashboard where you will fight the abstractions on every screen, or for a panel with two screens that a pair of Blade views would cover. Choosing it should be a deliberate decision about how much CRUD you have, not a reflex.

Where this usually goes wrong on real projects

The panel is rarely the hard part. The hard parts are deciding which operations staff are allowed to perform, making those operations idempotent and auditable, and keeping the panel fast as tables grow past a few million rows. If you are standing up a back office on an existing Laravel codebase — or trying to rescue one that has become slow and permissive — get in touch and we will scope it with you.