Filament v4: The Complete Guide from Zero to Production Dashboard

The definitive Filament v4 guide for Laravel 13: install, resources, relation managers, schemas, MFA, multi-tenancy, policies, Pest tests, and deploy.

Steven Richardson
Steven Richardson
· 15 min read

Most Filament tutorials stop at the first CRUD screen — and the distance between that screen and an admin panel a paying customer can actually use is enormous. You still need authorization, MFA, multi-tenancy, relation managers with pivot data, custom schema components, real tests, and a deploy story that survives the first cache miss in production.

This is the long version. Four hours, one Laravel 13 application, every layer of a Filament v4 admin panel you'd be happy to hand to a non-technical operations team on Monday morning. Code is copy-paste runnable on a fresh laravel new install.

Install Filament v4 and scaffold the admin panel#

Start with a working Laravel 13 application that already has user authentication. Filament v4 supports any auth scaffold — Breeze, Jetstream, Fortify, or your own — as long as App\Models\User is wired up. From the project root, install Filament and run the panel installer:

composer require filament/filament:"^4.0"
php artisan filament:install --panels

The installer asks for a panel ID — accept the default admin. It generates app/Providers/Filament/AdminPanelProvider.php, registers it in bootstrap/providers.php, and wires the /admin route. It also adds an empty app/Filament/Resources directory, an app/Filament/Pages directory, and a Dashboard page. The panel provider is now your single source of truth for everything panel-level — login, registration, MFA, tenancy, theming, middleware.

Open the generated provider:

<?php

namespace App\Providers\Filament;

use Filament\Panel;
use Filament\PanelProvider;
use Filament\Pages\Dashboard;

class AdminPanelProvider extends PanelProvider
{
    public function panel(Panel $panel): Panel
    {
        return $panel
            ->default()
            ->id('admin')
            ->path('admin')
            ->login()
            ->colors(['primary' => '#0F766E'])
            ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
            ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
            ->pages([Dashboard::class])
            ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
            ->authMiddleware(['web', 'auth']);
    }
}

Create a user (php artisan make:filament-user) and visit /admin. You now have a working panel — empty dashboard, login, dark mode toggle, breadcrumbs, the lot. v4 is built on Tailwind v4 and the oklch colour system, so dark mode and accent colours respond cleanly without a tailwind.config.js. If you've upgraded a v3 app, the breaking changes from v3 — Tailwind v4, schemas, the unified Actions namespace — are worth reviewing alongside the rest of the framework moves in the Laravel 12 to 13 upgrade guide.

Generate your first Resource with forms and tables#

A Resource is Filament's complete CRUD slice for a single Eloquent model — list page, create form, edit form, view page, table, filters, and bulk actions all in one. For this guide we're building an orders dashboard, so generate the Order model and its Filament resource together:

php artisan make:model Order -mf
php artisan make:filament-resource Order --generate

The --generate flag inspects the migration and produces a resource with form fields and table columns inferred from your column types. Open app/Filament/Resources/OrderResource.php and tighten it. v4 forms now use schemas — the same component tree powers form layout, infolist layout, and custom pages:

<?php

namespace App\Filament\Resources;

use App\Filament\Resources\OrderResource\Pages;
use App\Models\Order;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;

class OrderResource extends Resource
{
    protected static ?string $model = Order::class;

    protected static ?string $navigationIcon = 'heroicon-o-shopping-bag';

    public static function form(Schema $schema): Schema
    {
        return $schema->components([
            Section::make('Order details')
                ->columns(2)
                ->components([
                    Forms\Components\TextInput::make('reference')
                        ->required()
                        ->unique(ignoreRecord: true),
                    Forms\Components\Select::make('status')
                        ->options([
                            'pending'   => 'Pending',
                            'paid'      => 'Paid',
                            'shipped'   => 'Shipped',
                            'refunded'  => 'Refunded',
                        ])
                        ->required(),
                    Forms\Components\TextInput::make('total_cents')
                        ->numeric()
                        ->required()
                        ->suffix('¢'),
                    Forms\Components\DateTimePicker::make('placed_at'),
                ]),
        ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\Columns\TextColumn::make('reference')->searchable()->sortable(),
                Tables\Columns\TextColumn::make('status')->badge()->color(fn (string $state) => match ($state) {
                    'pending'  => 'warning',
                    'paid'     => 'success',
                    'shipped'  => 'info',
                    'refunded' => 'danger',
                }),
                Tables\Columns\TextColumn::make('total_cents')->money('GBP', divideBy: 100)->sortable(),
                Tables\Columns\TextColumn::make('placed_at')->dateTime()->sortable(),
            ])
            ->filters([
                Tables\Filters\SelectFilter::make('status')->options([
                    'pending' => 'Pending', 'paid' => 'Paid', 'shipped' => 'Shipped', 'refunded' => 'Refunded',
                ]),
            ])
            ->defaultSort('placed_at', 'desc');
    }

    public static function getPages(): array
    {
        return [
            'index'  => Pages\ListOrders::route('/'),
            'create' => Pages\CreateOrder::route('/create'),
            'edit'   => Pages\EditOrder::route('/{record}/edit'),
        ];
    }
}

That's a full, sortable, filterable, badged CRUD screen — about a hundred lines of declarative PHP. v4's rendering layer is 2–3× faster than v3 on large tables because Blade templates are consolidated and many UI elements are now rendered from PHP objects instead of being assembled from separate view files.

Real admin panels don't stop at top-level records. Every Order has LineItems, and those line items live in a many-to-many pivot with Product, carrying a quantity and a snapshotted price_cents. Generate the relation manager:

php artisan make:filament-relation-manager OrderResource products quantity --attach

Then on the Order model expose the pivot fields. Both sides of the relationship must declare withPivot() — this is the most common cause of "my pivot column is null" bugs:

public function products(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{
    return $this->belongsToMany(Product::class, 'order_lines')
        ->withPivot(['quantity', 'price_cents'])
        ->withTimestamps();
}

In the generated app/Filament/Resources/OrderResource/RelationManagers/ProductsRelationManager.php, wire the pivot fields into both the table columns and the attach modal:

public function table(Table $table): Table
{
    return $table
        ->recordTitleAttribute('name')
        ->columns([
            Tables\Columns\TextColumn::make('name')->searchable(),
            Tables\Columns\TextColumn::make('quantity')
                ->state(fn ($record) => $record->pivot->quantity),
            Tables\Columns\TextColumn::make('price_cents')
                ->money('GBP', divideBy: 100)
                ->state(fn ($record) => $record->pivot->price_cents),
        ])
        ->headerActions([
            Tables\Actions\AttachAction::make()
                ->preloadRecordSelect()
                ->form(fn (Tables\Actions\AttachAction $action) => [
                    $action->getRecordSelect(),
                    Forms\Components\TextInput::make('quantity')->numeric()->required()->minValue(1),
                    Forms\Components\TextInput::make('price_cents')->numeric()->required(),
                ]),
        ]);
}

AttachAction::getRecordSelect() returns the underlying select field; you append pivot inputs and Filament saves them to order_lines.quantity and order_lines.price_cents. The full pattern for many-to-many — including detach guards and validation on pivot fields — is covered in Filament v4 many-to-many relation managers if you need to go deeper than the attach flow shown here.

Build a custom Action with confirmation and notifications#

The unified Actions API is one of v4's biggest quality-of-life wins. There is now one Filament\Actions\Action class — the old split between Tables\Actions\Action, Pages\Actions\Action, and Forms\Components\Actions\Action is gone. The same Action instance drops into a table row, a header bar, a bulk dropdown, or an infolist.

Add a refund action to the order edit page. Open app/Filament/Resources/OrderResource/Pages/EditOrder.php:

<?php

namespace App\Filament\Resources\OrderResource\Pages;

use App\Filament\Resources\OrderResource;
use Filament\Actions\Action;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;

class EditOrder extends EditRecord
{
    protected static string $resource = OrderResource::class;

    protected function getHeaderActions(): array
    {
        return [
            Action::make('refund')
                ->label('Refund order')
                ->icon('heroicon-o-receipt-refund')
                ->color('danger')
                ->visible(fn () => $this->record->status === 'paid')
                ->requiresConfirmation()
                ->modalHeading('Refund this order?')
                ->modalDescription('Funds are returned to the customer immediately. This cannot be undone.')
                ->modalSubmitActionLabel('Yes, refund')
                ->form([
                    Forms\Components\Textarea::make('reason')
                        ->label('Reason for refund')
                        ->required()
                        ->maxLength(500),
                ])
                ->action(function (array $data) {
                    $this->record->refund($data['reason']);
                    Notification::make()
                        ->title('Order refunded')
                        ->success()
                        ->send();
                })
                ->failureNotificationTitle('Refund failed — see logs'),
        ];
    }
}

Three things to notice. requiresConfirmation() opens the modal with a destructive-action style; ->form([...]) collects typed input that arrives in the action closure as $data; Notification::make()->success()->send() triggers the toast at the top of the panel. If you need to render a Blade view inside the modal body — say a diff of what's about to change — use ->modalContent(view('admin.refund-preview', [...])). The full pattern, including custom modal content and failureNotificationTitle, is broken down in custom Filament v4 Actions with confirmation modals and notifications.

Add a custom Schema component to a page#

Schemas are v4's biggest architectural change. The same component tree that renders a form now renders infolists, page layouts, dashboard widgets, and custom pages — Section, Grid, Tabs, Wizard, Split all live in the new Filament\Schemas\Components namespace and compose anywhere.

That lets you mix a form, a table, and a custom view on one page with no Blade gymnastics. Create a KPI widget for the dashboard:

php artisan make:filament-widget OrderKpis --resource=OrderResource --stats-overview

Then for a richer custom schema component — say a "Recent activity" panel that doesn't fit the stats-overview shape — generate a generic component:

php artisan make:filament-schema-component RecentActivity

Filament writes app/Filament/Schemas/Components/RecentActivity.php and a Blade view. Render it on the dashboard by overriding the dashboard page schema:

<?php

namespace App\Filament\Pages;

use App\Filament\Schemas\Components\RecentActivity;
use Filament\Pages\Dashboard as BaseDashboard;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Schema;

class Dashboard extends BaseDashboard
{
    public function content(Schema $schema): Schema
    {
        return $schema->components([
            Grid::make(2)->components([
                $this->getWidgetsSchema(),
                RecentActivity::make(),
            ]),
        ]);
    }
}

No separate dashboard.blade.php to wrangle, no copying view files out of the vendor directory. The same pattern works for resource pages — override content() on ListOrders to interleave a custom component above the table. For details on writing the component class itself (state, hooks, view binding), see building a custom form field in Filament v4 — the schema lifecycle is the same.

Enable multi-factor authentication on the panel#

MFA used to mean shipping the pragmarx/google2fa package and writing the QR-code views yourself. v4 makes it native — both TOTP (Google Authenticator, Authy, 1Password) and email codes are first-class panel features. Enable both in the panel provider:

use Filament\Auth\MultiFactor\App\AppAuthentication;
use Filament\Auth\MultiFactor\Email\EmailAuthentication;

return $panel
    ->multiFactorAuthentication([
        AppAuthentication::make()
            ->recoveryCodes(),
        EmailAuthentication::make(),
    ])
    ->profile();

Run the migration Filament publishes for MFA secrets:

php artisan vendor:publish --tag=filament-actions-migrations
php artisan migrate

Users now see an MFA setup card on their profile page. Recovery codes are stored hashed and regenerable. For panels that hold sensitive customer data, set ->requiresMultiFactorAuthentication() on the panel — anyone without MFA configured is forced through setup on next login. For the wider Laravel auth story — passkeys, WebAuthn, and Fortify — pair this with the patterns in Laravel 13 passkeys with Fortify; MFA in Filament composes cleanly on top of either.

Configure multi-tenancy with team scoping#

For a SaaS admin panel where every customer team sees only its own data, v4's tenancy is the cleanest path. Add a Team model, give User a many-to-many relationship to it, then wire the panel.

The team model needs to implement the tenant contract:

<?php

namespace App\Models;

use Filament\Models\Contracts\HasCurrentTenantLabel;
use Illuminate\Database\Eloquent\Model;

class Team extends Model implements HasCurrentTenantLabel
{
    protected $fillable = ['name', 'slug'];

    public function getCurrentTenantLabel(): string
    {
        return $this->name;
    }

    public function members(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
    {
        return $this->belongsToMany(User::class);
    }
}

The User model implements HasTenants:

public function getTenants(\Filament\Panel $panel): \Illuminate\Support\Collection
{
    return $this->teams;
}

public function canAccessTenant(\Illuminate\Database\Eloquent\Model $tenant): bool
{
    return $this->teams->contains($tenant);
}

public function teams(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{
    return $this->belongsToMany(Team::class);
}

Register the tenant on the panel:

return $panel
    ->tenant(Team::class, slugAttribute: 'slug')
    ->tenantRegistration(\App\Filament\Pages\Tenancy\RegisterTeam::class);

Add a team_id to the orders table and Filament scopes the entire resource automatically — WHERE team_id = {current_tenant_id} is appended to every query without writing a single global scope. If you've already built tenancy at the Laravel layer for non-admin contexts, the patterns line up exactly with Laravel 13 multi-tenancy with teams — Filament's tenancy slots on top of the same team_id column.

The v4 multi-tenancy layer also exposes Unique::scoped() and Exists::scoped() validation rules so unique constraints respect tenant boundaries. Always use them on tenant-scoped models — without scoping, a unique reference in Team A blocks Team B from using the same string.

Lock down access with Laravel policies#

Tenancy answers which records can I see? Policies answer which records can I edit, delete, or refund? Filament reads Laravel policies natively — there is no separate authorization layer to learn.

Generate one:

php artisan make:policy OrderPolicy --model=Order

Fill it in with the actions that matter for an admin panel:

<?php

namespace App\Policies;

use App\Models\Order;
use App\Models\User;

class OrderPolicy
{
    public function viewAny(User $user): bool { return $user->can('orders.view'); }
    public function view(User $user, Order $order): bool { return $user->can('orders.view'); }
    public function create(User $user): bool { return $user->can('orders.create'); }
    public function update(User $user, Order $order): bool { return $user->can('orders.update'); }
    public function delete(User $user, Order $order): bool { return $user->can('orders.delete'); }
    public function refund(User $user, Order $order): bool { return $user->can('orders.refund'); }
}

Filament automatically resolves policy methods for resource pages — if viewAny returns false, the nav item is hidden and the list page returns 403. For custom actions, gate them inline:

Action::make('refund')
    ->authorize(fn (Order $record) => auth()->user()->can('refund', $record))
    // ...

Panel-level authorization runs even earlier — implement FilamentUser::canAccessPanel() on your User model so only operations staff can hit /admin at all:

public function canAccessPanel(\Filament\Panel $panel): bool
{
    return $this->is_staff && $this->hasVerifiedEmail();
}

Three gates — panel access, policy method, action-level authorize() — and a logged-in customer can never accidentally see a staff resource.

Write Pest tests for resources and actions#

Untested admin actions are how production refunds become production incidents. Filament's testing helpers boot Livewire pages directly, fill forms, call actions, and assert results — no browser, no JavaScript runtime, fast enough to run on every commit.

Generate the test:

php artisan make:test --pest OrderResourceTest

Cover the three things that matter — the create form, the table list, and the refund action:

<?php

use App\Filament\Resources\OrderResource;
use App\Filament\Resources\OrderResource\Pages\CreateOrder;
use App\Filament\Resources\OrderResource\Pages\EditOrder;
use App\Filament\Resources\OrderResource\Pages\ListOrders;
use App\Models\Order;
use App\Models\User;

use function Pest\Livewire\livewire;

beforeEach(function () {
    $this->actingAs(User::factory()->staff()->create());
});

it('lists orders for the current tenant', function () {
    $orders = Order::factory()->count(3)->create();

    livewire(ListOrders::class)
        ->assertCanSeeTableRecords($orders);
});

it('creates an order with valid data', function () {
    livewire(CreateOrder::class)
        ->fillForm([
            'reference'   => 'ORD-1001',
            'status'      => 'pending',
            'total_cents' => 4999,
        ])
        ->call('create')
        ->assertHasNoFormErrors();

    expect(Order::where('reference', 'ORD-1001')->exists())->toBeTrue();
});

it('refunds a paid order via the action', function () {
    $order = Order::factory()->paid()->create();

    livewire(EditOrder::class, ['record' => $order->getRouteKey()])
        ->callAction('refund', data: ['reason' => 'Customer complaint'])
        ->assertHasNoActionErrors()
        ->assertNotified('Order refunded');

    expect($order->fresh()->status)->toBe('refunded');
});

callAction('refund', data: [...]) opens the modal, fills the form, submits, and runs the closure — all in-process. assertNotified() checks Filament's notification bus. Run the full feature test suite with composer test — these tests typically run in under a second each because no HTTP layer is involved. For browser-level smoke tests on top of this (visual regressions, full user flows), the in-process pattern complements Pest 4 browser testing with Playwright rather than replacing it.

Deploy to production with cache warming and asset publishing#

A Filament panel that's flawless in development can be sluggish on the first production hit. v4 ships a component cache that indexes every resource, page, and widget so the panel doesn't have to discover them on each request. Run it once on every deploy:

php artisan filament:optimize

That command is shorthand for php artisan filament:cache-components plus php artisan icons:cache. The component cache lives in bootstrap/cache/filament — clear it on the way in with filament:optimize-clear if you've changed any resource discovery rules.

Livewire's runtime JavaScript needs static-asset publishing too, otherwise every request hits /livewire/livewire.min.js through PHP-FPM instead of nginx:

php artisan livewire:publish --assets
php artisan filament:assets

Add both to your composer.json so they run on every deploy automatically:

{
    "scripts": {
        "post-autoload-dump": [
            "@php artisan filament:upgrade",
            "@php artisan livewire:publish --assets --force"
        ]
    }
}

A full deploy looks like this:

git pull --rebase
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan filament:optimize
php artisan optimize
php artisan queue:restart

For zero-downtime, run the script behind atomic release symlinks — the full pattern is in zero-downtime Laravel deployment with GitHub Actions and Forge. On heavier traffic panels, put Octane or FrankenPHP in front; Laravel Octane + FrankenPHP for production deployment walks through the boot-time gotchas Filament panels hit when running on a persistent worker.

Lock the panel before the first real user logs in: rate-limit /admin/login (Route::middleware('throttle:5,1') in your panel middleware stack), set ->requiresMultiFactorAuthentication() for staff, run php artisan filament:check-translations against any non-English locales, and seed real policies — not the generated return true placeholders. The panel you ship next sprint is the same panel you'll be debugging at 2am in eighteen months: write the tests, name the policies, document the actions. Future you will care.

FAQ#

What is FilamentPHP and why use it in Laravel?

FilamentPHP is a full-stack admin panel framework for Laravel, built on Livewire, Alpine, and Tailwind. You declare resources, forms, tables, and actions in PHP and Filament renders the entire admin UI server-side — no separate frontend project, no API to maintain. It's the fastest path from "I have an Eloquent model" to "non-developers can manage records in production", and v4 extends that to schema-driven custom pages, MFA, and multi-tenancy without third-party packages.

What changed in Filament v4 compared with v3?

The headline changes are Tailwind v4 with the oklch colour system, a unified Schemas package that powers forms, infolists, and custom pages from one component tree, native multi-factor authentication, a single unified Actions namespace replacing the three v3 action classes, and 2–3× faster rendering on large tables. Multi-tenancy gained automatic global scopes and scopedUnique()/scopedExists() validation, and the panel provider now configures MFA, tenancy, and theming declaratively.

How do I install Filament v4 in a Laravel 13 application?

Require the package with composer require filament/filament:"^4.0", then run php artisan filament:install --panels and accept the default admin panel ID. The installer publishes a panel provider, registers it in bootstrap/providers.php, and wires the /admin route. Create a panel user with php artisan make:filament-user and visit /admin to confirm everything works before generating resources.

How do I create a CRUD resource in Filament v4?

Generate the resource alongside its model with php artisan make:filament-resource Order --generate. The --generate flag inspects the model's migration and produces a starter form schema and table column set inferred from your column types. Refine the form via public static function form(Schema $schema), the table via public static function table(Table $table), and Filament handles routing, validation, and persistence end-to-end.

How do I add multi-factor authentication to a Filament panel?

Call ->multiFactorAuthentication([AppAuthentication::make()->recoveryCodes(), EmailAuthentication::make()]) on the panel provider, then run php artisan vendor:publish --tag=filament-actions-migrations and php artisan migrate to add the MFA secret columns. Users set up TOTP or email codes from their profile page. Add ->requiresMultiFactorAuthentication() on the panel to force every staff user through MFA setup on next login.

How does multi-tenancy work in Filament v4?

Tell the panel which model is the tenant with ->tenant(Team::class, slugAttribute: 'slug'), implement HasTenants on the User model, and add a team_id column to any tenant-scoped resource. Filament automatically appends WHERE team_id = current_tenant_id to every resource query, scopes relation managers, and isolates uploads. Use Unique::scoped() and Exists::scoped() validation rules so unique constraints respect tenant boundaries — without them, one tenant blocks another from using the same string.

How do I deploy a Filament panel to production safely?

Run php artisan filament:optimize on every deploy to cache components and icons, publish Livewire and Filament assets with php artisan livewire:publish --assets --force and php artisan filament:assets, then run php artisan migrate --force, php artisan optimize, and php artisan queue:restart. Combine that with atomic release symlinks, rate-limited login routes, panel-level MFA, and real policies — never the generated return true placeholders — before any non-staff user can reach /admin.

How do I write Pest tests for Filament resources?

Use the livewire() helper from Pest\Livewire to boot resource pages directly, then chain fillForm(), call(), callAction(), and assertCanSeeTableRecords() to drive the panel without a browser. For example, livewire(EditOrder::class, ['record' => $order->getRouteKey()])->callAction('refund', data: ['reason' => 'x']) opens the modal, submits the form, and runs the action in-process — fast enough to run on every commit and reliable enough to gate production deploys.

Do I need Livewire knowledge to use Filament v4?

You can build the first 80% of a Filament panel without writing a single Livewire directive — resources, forms, tables, and actions are fully declarative PHP. Once you start writing custom pages, custom form fields, or schema components, you'll touch Livewire idioms like wire:model, wire:click, and event listeners. The transition is gentle and the Livewire 3 → 4 changes Filament v4 builds on are documented in the Livewire 3 to 4 migration guide.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.