Building Admin Dashboards with Filament: A Complete Guide for Laravel Developers
Learn how to build powerful admin dashboards with Filament v4 in Laravel. Complete guide with code examples, best practices, and real-world tips.
Admin panels used to drain weeks from my project timelines. Forms, validation, tables, filters, all that repetitive CRUD code ate up development time I didn't have. Then I discovered Filament v4.
When I built StudyLab's admin dashboard to manage 500+ schools, Filament saved me roughly 40 hours. What normally takes weeks turned into days. The difference? Filament handles the boring stuff so I can focus on features that actually matter.
This guide uses Filament v4 (released August 12, 2025). If you're upgrading from v3, check the official upgrade guide because the API changed significantly, especially namespaces and how forms/tables are organized.
You'll learn how to install Filament v4, create resources with the new schema structure, handle relationships properly, customize forms and tables, and avoid mistakes that'll cost you debugging hours. Plus, I'm sharing production techniques from apps handling real users.
What Makes Filament v4 Different
Filament v4 isn't just an update. It's a rethink.
The biggest change? Separate schema files. Instead of cramming everything into your resource class, v4 generates dedicated files for forms (Schemas/CustomerForm.php) and tables (Tables/CustomersTable.php). Your resources stay clean. Your code stays maintainable.
The actions system got unified too. Everything lives in Filament\Actions now, no more hunting through separate namespaces for table actions vs form actions. One namespace. One consistent API.
Performance improved dramatically. The new live() method (replacing reactive()) is smarter about when to trigger updates. Client-side JavaScript helpers like hiddenJs() and visibleJs() eliminate unnecessary server roundtrips for simple show/hide logic.
And Filament switched from TinyMCE to Tiptap for rich text editing. Lighter. Faster. Better.
Here's why this matters for your projects: Filament is built on TALL stack (Tailwind, Alpine.js, Laravel, Livewire). You're not learning proprietary syntax. When you customize something, you're writing regular Livewire components. When you need styling, you're using Tailwind classes you already know.
Plus, it's free. No licensing fees. No per-seat costs. I manage hundreds of admin users in StudyLab without spending a dollar on the panel itself.
Installing Filament v4
You need Laravel 11 (or Laravel 10). I run all my projects on Laravel 11 with PHP 8.3.
Install via Composer:
composer require filament/filament:"^4.0"
Run the installation:
php artisan filament:install --panels
This command creates your admin panel in app/Filament, sets up your panel provider, publishes assets, and configures everything. The setup is cleaner than v3, one command does it all.
Create an admin user:
php artisan make:filament-user
Enter your email and password. Done.
Visit /admin. Log in. You've got a working panel.
Two minutes from zero to functional.
Creating Your First Resource
Resources connect your Eloquent models to the admin panel. They're like controllers but way more powerful.
Generate a User resource:
php artisan make:filament-resource User --generate
The --generate flag inspects your User model and automatically creates form fields and table columns based on your database schema. In v4, this generation is smarter, it recognizes relationships and suggests appropriate field types.
This creates several files in app/Filament/Resources/Users/:
app/Filament/Resources/Users/
├── UserResource.php
├── Pages/
│ ├── CreateUser.php
│ ├── EditUser.php
│ └── ListUsers.php
├── Schemas/
│ └── UserForm.php
└── Tables/
└── UsersTable.php
The structure is new in v4. Your resource file stays minimal. Forms live in Schemas/. Tables live in Tables/. Way cleaner than v3's massive resource classes.
Here's the generated UserResource.php:
<?php
namespace App\Filament\Resources\Users;
use App\Filament\Resources\Users\Pages\CreateUser;
use App\Filament\Resources\Users\Pages\EditUser;
use App\Filament\Resources\Users\Pages\ListUsers;
use App\Filament\Resources\Users\Schemas\UserForm;
use App\Filament\Resources\Users\Tables\UsersTable;
use App\Models\User;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class UserResource extends Resource
{
protected static ?string $model = User::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
public static function form(Schema $schema): Schema
{
return UserForm::configure($schema);
}
public static function table(Table $table): Table
{
return UsersTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListUsers::route('/'),
'create' => CreateUser::route('/create'),
'edit' => EditUser::route('/{record}/edit'),
];
}
}
Notice how clean it is? The form and table configuration live elsewhere.
Check UserForm.php:
<?php
namespace App\Filament\Resources\Users\Schemas;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class UserForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->required()
->maxLength(255),
TextInput::make('email')
->email()
->required()
->maxLength(255),
DateTimePicker::make('email_verified_at'),
TextInput::make('password')
->password()
->required()
->maxLength(255),
]);
}
}
And UsersTable.php:
<?php
namespace App\Filament\Resources\Users\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class UsersTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')->searchable(),
TextColumn::make('email')->searchable(),
TextColumn::make('email_verified_at')
->dateTime()
->sortable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}
Refresh /admin. You've got full CRUD for users.
But this is just the start.
Customizing Forms in v4
Forms in v4 use the Schema object with components(). The structure differs from v3.
Here's how I structure forms in ReplyGenius:
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Schema;
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make('User Information')
->schema([
TextInput::make('name')
->required()
->maxLength(255)
->columnSpan(1),
TextInput::make('email')
->email()
->required()
->unique(ignoreRecord: true)
->columnSpan(1),
])
->columns(2),
Section::make('Extension Settings')
->schema([
Toggle::make('extension_enabled')
->label('Enable Chrome Extension')
->default(true),
Select::make('plan')
->options([
'free' => 'Free',
'pro' => 'Pro',
'enterprise' => 'Enterprise',
])
->required()
->live() // v4 uses live() instead of reactive()
->afterStateUpdated(function ($state, $set) {
if ($state === 'free') {
$set('monthly_replies_limit', 50);
} elseif ($state === 'pro') {
$set('monthly_replies_limit', 500);
} else {
$set('monthly_replies_limit', null);
}
}),
TextInput::make('monthly_replies_limit')
->numeric()
->label('Monthly Reply Limit')
->helperText('Leave empty for unlimited'),
])
->collapsible(),
Section::make('API Configuration')
->schema([
TextInput::make('api_key')
->label('API Key')
->default(fn () => \Str::random(32))
->disabled()
->helperText('Generated automatically'),
])
->collapsible()
->collapsed(),
]);
}
Key differences from v3:
- Use
live()instead ofreactive() - Schema wraps everything with
->components([]) - Sections organize related fields cleanly
The live() method makes forms respond to changes. When someone selects a plan, the reply limit updates automatically. This used to require custom JavaScript. Now it's one method.
Filament v4 also introduced afterStateUpdatedJs() for client-side updates that don't hit the server:
TextInput::make('name')
->afterStateUpdatedJs(<<<'JS'
$set('slug', ($state ?? '').replaceAll(' ', '-').toLowerCase())
JS)
This eliminates server roundtrips for simple operations. Fast.
Working With Relationships
Relationships are where Filament shines. Let me show you StudyLab's approach.
I have schools with many teachers. Here's the model:
// School model
public function teachers()
{
return $this->hasMany(Teacher::class);
}
In your form schema, use a Repeater:
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
Repeater::make('teachers')
->relationship('teachers')
->schema([
TextInput::make('name')
->required(),
TextInput::make('email')
->email()
->required(),
Select::make('subject')
->options([
'math' => 'Mathematics',
'science' => 'Science',
'english' => 'English',
]),
])
->collapsible()
->itemLabel(fn (array $state): ?string => $state['name'] ?? null)
->addActionLabel('Add Teacher')
->minItems(1)
This creates an interface where you add multiple teachers directly from the school form. The itemLabel() method shows the teacher's name when collapsed.
For complex relationships, use Relation Managers:
php artisan make:filament-relation-manager SchoolResource teachers name
This generates app/Filament/Resources/SchoolResource/RelationManagers/TeachersRelationManager.php:
<?php
namespace App\Filament\Resources\SchoolResource\RelationManagers;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Filament\Actions\CreateAction;
use Filament\Actions\EditAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\BulkActionGroup;
class TeachersRelationManager extends RelationManager
{
protected static string $relationship = 'teachers';
public function form(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')->required(),
TextInput::make('email')->email()->required(),
Select::make('subject')
->options([
'math' => 'Mathematics',
'science' => 'Science',
'english' => 'English',
]),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('name')
->columns([
TextColumn::make('name')->searchable(),
TextColumn::make('email')->searchable(),
TextColumn::make('subject'),
TextColumn::make('created_at')->dateTime()->sortable(),
])
->filters([])
->headerActions([
CreateAction::make(),
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}
Register it in your resource's getRelations() method:
public static function getRelations(): array
{
return [
RelationManagers\TeachersRelationManager::class,
];
}
Now when you edit a school, you see a "Teachers" tab with a full table. Filter, search, and bulk delete right there. In StudyLab, this lets school admins manage teachers without leaving the school record.
Customizing Tables in v4
Tables in v4 use new methods. Pay attention, this tripped me up initially.
Key changes:
recordActions()for row actions (not justactions())toolbarActions()for bulk actions (notbulkActions())- Actions live in
Filament\Actionsnamespace
Here's a complete example from ReplyGenius:
use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Support\Enums\FontWeight;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Notification;
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->searchable()
->sortable()
->description(fn ($record): string => $record->email)
->weight(FontWeight::Bold),
TextColumn::make('schools_count')
->counts('schools')
->label('Schools')
->sortable()
->badge()
->color('success'),
IconColumn::make('extension_enabled')
->boolean()
->label('Extension'),
TextColumn::make('plan')
->badge()
->color(fn (string $state): string => match ($state) {
'free' => 'gray',
'pro' => 'success',
'enterprise' => 'warning',
}),
TextColumn::make('created_at')
->dateTime()
->sortable()
->since()
->description(fn ($record): string =>
$record->created_at->format('M j, Y')
),
])
->filters([
SelectFilter::make('plan')
->options([
'free' => 'Free',
'pro' => 'Pro',
'enterprise' => 'Enterprise',
]),
TernaryFilter::make('extension_enabled')
->label('Extension Enabled')
->placeholder('All users')
->trueLabel('Enabled')
->falseLabel('Disabled'),
])
->recordActions([
EditAction::make(),
Action::make('resetPassword')
->icon('heroicon-o-key')
->action(function ($record) {
$record->update([
'password' => bcrypt('temporary-password'),
]);
Notification::make()
->title('Password reset')
->success()
->send();
})
->requiresConfirmation(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
Action::make('enableExtension')
->label('Enable Extension')
->icon('heroicon-o-check-circle')
->action(fn (Collection $records) =>
$records->each->update(['extension_enabled' => true])
)
->deselectRecordsAfterCompletion(),
]),
])
->defaultSort('created_at', 'desc');
}
Notice recordActions() and toolbarActions()? That's v4 syntax. Using old v3 methods won't work.
The description() method adds secondary text below values. I show email addresses under names, keeps tables compact but informative.
Badge columns work great for status fields. The color() method uses match expressions for styling.
Custom actions like resetPassword appear in row dropdowns. One-off operations that don't need full forms.
Bulk actions let users select multiple records and act on them. In StudyLab, school admins bulk-enable extensions for new teachers.
Advanced Form Techniques
These took me months to discover.
Conditional Fields
Show/hide fields based on other values:
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Schemas\Components\Utilities\Get;
Select::make('subscription_type')
->options([
'monthly' => 'Monthly',
'annual' => 'Annual',
'lifetime' => 'Lifetime',
])
->required()
->live(),
DatePicker::make('subscription_end_date')
->required()
->visible(fn (Get $get): bool =>
in_array($get('subscription_type'), ['monthly', 'annual'])
)
The live() method (v4) replaces reactive() for better performance. The visible() closure gets form state and returns whether to show the field.
Dependent Selects
Load options based on another field:
use Filament\Forms\Components\Select;
use Filament\Schemas\Components\Utilities\Get;
Select::make('school_id')
->relationship('school', 'name')
->searchable()
->live()
->required(),
Select::make('teacher_id')
->options(function (Get $get) {
$schoolId = $get('school_id');
if (!$schoolId) {
return [];
}
return Teacher::where('school_id', $schoolId)
->pluck('name', 'id');
})
->searchable()
->required()
This pattern is everywhere in StudyLab. Select a school, teacher dropdown updates to show only teachers from that school.
File Uploads with Image Editor
use Filament\Forms\Components\FileUpload;
FileUpload::make('avatar')
->image()
->disk('public')
->directory('avatars')
->visibility('public')
->imageEditor() // NEW in v4
->imageEditorAspectRatios(['1:1'])
->maxSize(2048)
->helperText('Upload a profile picture (max 2MB)')
The imageEditor() method in v4 is fantastic. Users crop and resize images right in the panel. No external tools needed.
Rich Text with Tiptap
Filament v4 switched to Tiptap:
use Filament\Forms\Components\RichEditor;
RichEditor::make('bio')
->toolbarButtons([
'bold',
'italic',
'strike',
'link',
'bulletList',
'orderedList',
'h2',
'h3',
])
->maxLength(65535)
->columnSpanFull()
I limit toolbar buttons. Most users don't need the full formatting arsenal.
Performance Optimization
Admin panels get slow with thousands of records. Here's how I keep things fast.
Eager Loading
Modify queries in your resource:
use Illuminate\Database\Eloquent\Builder;
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->with(['schools', 'teachers'])
->withCount('students');
}
This prevents N+1 queries. Use withCount() when you only need counts, way faster than loading full relationships.
Caching Expensive Calculations
use Illuminate\Support\Facades\Cache;
use Filament\Tables\Columns\TextColumn;
TextColumn::make('total_revenue')
->state(function ($record): string {
return Cache::remember(
"user.{$record->id}.total_revenue",
now()->addHour(),
fn () => $record->orders()->sum('total')
);
})
->money('usd')
In StudyLab's school dashboard, I cache student counts and quiz completion rates. Reduced load time from 2 seconds to 300ms.
Custom Actions in v4
Actions are unified in v4. Everything's in Filament\Actions.
Here's a real example from StudyLab:
use App\Jobs\GenerateQuizQuestionsJob;
use App\Models\Quiz;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
Action::make('generateQuiz')
->label('Generate Quiz')
->icon('heroicon-o-sparkles')
->form([
Select::make('subject')
->options([
'math' => 'Mathematics',
'science' => 'Science',
'english' => 'English',
])
->required(),
TextInput::make('num_questions')
->numeric()
->default(10)
->minValue(5)
->maxValue(50)
->required(),
])
->action(function (array $data, $record): void {
$quiz = Quiz::create([
'teacher_id' => $record->id,
'subject' => $data['subject'],
'num_questions' => $data['num_questions'],
'status' => 'draft',
]);
GenerateQuizQuestionsJob::dispatch($quiz);
Notification::make()
->title('Quiz generation started')
->body("Your {$data['num_questions']}-question quiz will be ready soon.")
->success()
->send();
})
->requiresConfirmation()
->modalHeading('Generate New Quiz')
->modalDescription('Creates a quiz using AI based on the selected subject.')
->modalSubmitActionLabel('Generate')
This adds a "Generate Quiz" button to each teacher row. Click it, fill the form, dispatches a background job. Teacher gets a notification when done.
Common Mistakes to Avoid
I've made these. Learn from my pain.
Using Old v3 Syntax
Don't use actions() and bulkActions(). Use recordActions() and toolbarActions(). The old methods don't exist in v4.
Not Eager Loading Relationships
If your table shows teacher.school.name, eager load it in getEloquentQuery(). Otherwise, you hit N+1 hell. StudyLab's teacher list took 8 seconds because I lazily loaded schools.
Forgetting Form Validation
Add ->required(), ->email(), ->numeric(). Filament handles error display, but you specify rules.
Ignoring Mobile Responsiveness
Filament is responsive by default, but custom components might not be. Test on tablets. Admins use mobile devices more than you think.
Not Using Sections
Long forms overwhelm users. Break them into sections with descriptive headings.
Coming From Filament v3?
Major changes in v4:
Separate Schema Files
Forms go in Schemas/. Tables go in Tables/. Your resource file stays clean.
Unified Actions Namespace
Everything's in Filament\Actions. No more hunting through separate namespaces.
Performance Improvements
Client-side JavaScript hooks (hiddenJs(), visibleJs(), afterStateUpdatedJs()) eliminate unnecessary Livewire roundtrips.
Tiptap Rich Editor
TinyMCE is gone. If you customized TinyMCE heavily, you'll need to rewrite for Tiptap.
New Methods
live()replacesreactive()recordActions()replacesactions()toolbarActions()replacesbulkActions()
Check the upgrade guide for the complete list.
When to Use Filament
Perfect for:
- Internal admin panels with controlled user bases
- CRUD-heavy applications (schools, inventory, CRM)
- Projects where rapid development beats pixel-perfect design
- Teams comfortable with Livewire and Tailwind
Skip it for:
- Public-facing interfaces (Filament is for admins)
- Heavy custom JavaScript requirements (Livewire has limits)
- Non-Laravel projects
- Teams unwilling to learn Livewire
In those cases, build with Inertia.js and Vue/React.
Real-World Production Tips
From running StudyLab and ReplyGenius:
Add Activity Logging
Install spatie/laravel-activitylog. Log important changes. When someone deletes all teachers, you'll know who and when.
Set Up Proper Permissions
Filament integrates with Laravel policies. Use them. Not everyone should delete users or modify billing settings.
Monitor Performance
Install Laravel Debugbar or Telescope. Watch for N+1 queries. I caught a relationship query loading 500+ records per page. Fixed it with eager loading.
Customize the Dashboard
The default dashboard is boring. Add widgets showing key metrics. StudyLab admins see total students, active quizzes, and completion rates on the dashboard.
Use Queue Jobs for Slow Operations
If an action takes more than 2 seconds, dispatch a job. Users don't want to wait.
Next Steps
Build something.
Start with simple CRUD for one model. Get comfortable with resources, forms, and tables. Then add relationships. Then custom actions. Then widgets.
The best way to learn Filament is by building with it. Don't just read (though this guide helps). Pick a project and code.
The official documentation is excellent. Seriously some of the best docs for any Laravel package.
Remember: Filament is just Laravel. If you can build Laravel apps, you can build Filament panels. It's not magic, it's well-designed abstractions over tools you know.
Need help implementing this? I've built production admin panels for SaaS apps managing thousands of users. If you're stuck on complex implementations or want guidance architecting your admin panel, let's work together: Contact me
Need Help With Your Laravel Project?
I specialize in building custom Laravel applications, process automation, and SaaS development. Whether you need to eliminate repetitive tasks or build something from scratch, let's discuss your project.
⚡ Currently available for 2-3 new projects
About Hafiz Riaz
Full Stack Developer from Turin, Italy. I build web applications with Laravel and Vue.js, and automate business processes. Creator of ReplyGenius, StudyLab, and other SaaS products.
View Portfolio →Get web development tips via email
Join 50+ developers • No spam • Unsubscribe anytime
Related Articles
Setting Up Laravel 10 with Vue3 and Vuetify3
A complete guide to seamlessly integrating Vue3 and Vuetify3 into Laravel 10 usi...
Effortlessly Dockerize Your Laravel & Vue Application: A Step-by-Step Guide
Unleash the Full Potential of Laravel 10 with Vue 3 and Vuetify 3 in a Dockerize...
Mastering Design Patterns in Laravel
Unraveling the Secrets Behind Laravel’s Architectural Mastery