Every internal tool eventually needs an admin panel. Whether you ship a SaaS product, run an e-commerce store, or operate an internal CRM, someone on the team will need a clean interface to manage users, orders, content, and settings. In the Laravel ecosystem, the answer in 2026 is unambiguous — Filament v3 is now the dominant solution. It combines the productivity of Livewire 3, the elegance of Tailwind CSS v4, and a deeply opinionated component system that lets you build a polished admin panel in hours instead of weeks.
In this tutorial, we will build NoqtaShop Admin, a fully functional admin panel for a fictional e-commerce backend. By the end, you will have a working dashboard with products, categories, orders, role-based access control, multi-tenancy, custom widgets, and a deployable production build.
Prerequisites
Before starting, ensure you have:
- PHP 8.3 or newer installed
- Composer 2.7 or newer
- Node.js 20 or newer for asset compilation
- A working knowledge of Laravel routing, models, and migrations
- Familiarity with Eloquent and Blade
- A code editor such as VS Code or PhpStorm
- A local database (PostgreSQL or MySQL — we will use PostgreSQL)
This tutorial assumes intermediate Laravel experience. If you are brand new to Laravel, run through the official Laravel Bootcamp first, then return here.
What You Will Build
NoqtaShop Admin will include:
- A polished login screen with email and password authentication
- A dashboard with revenue widgets, recent orders, and a sales chart
- Resources for products, categories, and orders, each with full CRUD pages
- Searchable, filterable, and sortable tables with bulk actions
- Custom form components for product variants and rich-text descriptions
- Role-based permissions powered by spatie/laravel-permission
- A multi-tenant boundary so each shop owner only sees their own data
- File uploads to local storage with image previews
- A production-ready build with queued notifications and database backups
Why Filament v3 in 2026
Three years ago, building a Laravel admin panel meant gluing together Nova licenses, Backpack add-ons, or hand-rolled Vue dashboards. Each option had real trade-offs. Nova was expensive and slow to evolve, Backpack required intricate configuration, and DIY dashboards reinvented the wheel every project.
Filament v3 changed the equation. It is open source, it ships with Livewire 3 and Alpine.js out of the box, and its component library is the most refined in the PHP ecosystem. The v3 release added panel switching, multi-tenancy, custom themes, and an action system that lets you compose buttons, modals, and confirmations declaratively. The community has grown enormous, with hundreds of community plugins covering everything from media libraries to GDPR compliance.
Compared to Backpack, Filament gives you a far more cohesive design language and fewer surprises when you customize. Compared to Nova, it costs nothing and evolves at a faster cadence. Compared to a hand-built React admin, it ships in days rather than months.
Step 1: Project Setup
Let's start by creating a fresh Laravel 11 project. Open your terminal and run the Laravel installer.
composer create-project laravel/laravel:^11.0 noqtashop
cd noqtashopConfigure your database in the .env file. We recommend PostgreSQL for its richer JSON and full-text search support, but MySQL works equally well.
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=noqtashop
DB_USERNAME=postgres
DB_PASSWORD=secret
APP_NAME="NoqtaShop Admin"
APP_URL=http://localhost:8000Create the database, then run the initial migrations to make sure everything connects.
createdb noqtashop
php artisan migrateIf the migrations succeed, you are ready to install Filament.
Step 2: Install Filament v3
Install the Filament panel package using Composer.
composer require filament/filament:"^3.3" -WNow run the panel installer. Filament will scaffold a panel provider, register routes, and publish a starter theme.
php artisan filament:install --panelsWhen prompted for a panel ID, enter admin. The installer creates app/Providers/Filament/AdminPanelProvider.php, which is the entry point for all admin configuration.
Create your first admin user. This command creates a row in the users table and grants access to the panel.
php artisan make:filament-userFill in the prompts with your name, email, and a strong password. Now start the development server and the Vite dev server in two terminals.
php artisan serve
npm install && npm run devVisit http://localhost:8000/admin and sign in. You will see an empty Filament dashboard. The chrome is already polished — sidebar, top bar, dark mode toggle, and breadcrumbs are all working out of the box.
Step 3: Model the Domain
Before generating Filament resources, we need real data. Create the domain models for our shop.
php artisan make:model Category -m
php artisan make:model Product -m
php artisan make:model Order -m
php artisan make:model OrderItem -mOpen database/migrations/xxxx_create_categories_table.php and define the schema.
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->boolean('is_visible')->default(true);
$table->unsignedBigInteger('parent_id')->nullable();
$table->foreign('parent_id')->references('id')->on('categories')->nullOnDelete();
$table->timestamps();
});Define the products table next. We use JSON columns for variants and a polymorphic relation for media.
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->foreignId('category_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->decimal('price', 10, 2);
$table->integer('stock')->default(0);
$table->string('sku')->unique();
$table->boolean('is_visible')->default(true);
$table->boolean('is_featured')->default(false);
$table->json('variants')->nullable();
$table->string('image')->nullable();
$table->timestamps();
});The orders schema is straightforward — a header row plus a line items table.
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('number')->unique();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('status')->default('pending');
$table->decimal('total', 10, 2);
$table->string('currency', 3)->default('USD');
$table->text('notes')->nullable();
$table->timestamps();
});
Schema::create('order_items', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained();
$table->integer('quantity');
$table->decimal('unit_price', 10, 2);
$table->timestamps();
});Add the relationships to the models. Open app/Models/Product.php and define the connections.
class Product extends Model
{
protected $fillable = [
'category_id', 'name', 'slug', 'description',
'price', 'stock', 'sku', 'is_visible', 'is_featured',
'variants', 'image',
];
protected $casts = [
'price' => 'decimal:2',
'is_visible' => 'boolean',
'is_featured' => 'boolean',
'variants' => 'array',
];
public function category()
{
return $this->belongsTo(Category::class);
}
}Run the migrations and seed a few categories so we have something to work with.
php artisan migrate
php artisan tinkerInside Tinker, create three sample categories.
\App\Models\Category::create(['name' => 'Apparel', 'slug' => 'apparel']);
\App\Models\Category::create(['name' => 'Electronics', 'slug' => 'electronics']);
\App\Models\Category::create(['name' => 'Books', 'slug' => 'books']);
exitStep 4: Generate Your First Resource
Filament resources are PHP classes that describe how a model should be displayed in the panel. Generate one for the Category model.
php artisan make:filament-resource Category --generateThe --generate flag tells Filament to inspect the database columns and produce a sensible default schema. Refresh the admin panel — you will see a new Categories link in the sidebar.
Click into Categories. The list view has a table with columns for name and slug, and a Create Category button takes you to a form. The CRUD is functional with zero hand-written code.
Now let's customize it. Open app/Filament/Resources/CategoryResource.php and refine the form.
public static function form(Form $form): Form
{
return $form->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255)
->live(onBlur: true)
->afterStateUpdated(fn (string $state, callable $set) =>
$set('slug', Str::slug($state))
),
Forms\Components\TextInput::make('slug')
->required()
->unique(ignoreRecord: true),
Forms\Components\Textarea::make('description')
->rows(4),
Forms\Components\Toggle::make('is_visible')
->label('Visible to customers')
->default(true),
Forms\Components\Select::make('parent_id')
->label('Parent category')
->relationship('parent', 'name')
->searchable(),
]);
}The live(onBlur: true) modifier tells Filament to listen for blur events and auto-fill the slug when a name is entered. This kind of small touch is what makes Filament feel finished.
Step 5: Build a Polished Product Resource
Run the generator for products as well.
php artisan make:filament-resource Product --generateOpen ProductResource.php and replace the form schema with a richer version that uses sections and columns.
public static function form(Form $form): Form
{
return $form->schema([
Forms\Components\Section::make('Basic information')
->columns(2)
->schema([
Forms\Components\TextInput::make('name')
->required()
->live(onBlur: true)
->afterStateUpdated(fn ($state, callable $set) =>
$set('slug', Str::slug($state))
),
Forms\Components\TextInput::make('slug')
->required()
->unique(ignoreRecord: true),
Forms\Components\Select::make('category_id')
->relationship('category', 'name')
->required()
->searchable()
->preload(),
Forms\Components\TextInput::make('sku')
->required()
->unique(ignoreRecord: true),
]),
Forms\Components\Section::make('Pricing and stock')
->columns(3)
->schema([
Forms\Components\TextInput::make('price')
->numeric()
->prefix('$')
->required(),
Forms\Components\TextInput::make('stock')
->numeric()
->default(0),
Forms\Components\Toggle::make('is_visible')
->default(true),
]),
Forms\Components\Section::make('Description')
->schema([
Forms\Components\RichEditor::make('description')
->columnSpanFull(),
]),
Forms\Components\Section::make('Image')
->schema([
Forms\Components\FileUpload::make('image')
->image()
->imageEditor()
->directory('products')
->columnSpanFull(),
]),
]);
}The form now feels like a real product editor — sections group related fields, a rich text editor handles the description, and the file upload component supports cropping and resizing in the browser.
Next, customize the table.
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\ImageColumn::make('image')
->circular(),
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('category.name')
->label('Category')
->sortable(),
Tables\Columns\TextColumn::make('price')
->money('USD')
->sortable(),
Tables\Columns\TextColumn::make('stock')
->sortable()
->badge()
->color(fn (int $state): string => match (true) {
$state === 0 => 'danger',
$state < 10 => 'warning',
default => 'success',
}),
Tables\Columns\IconColumn::make('is_visible')
->boolean(),
])
->filters([
Tables\Filters\SelectFilter::make('category')
->relationship('category', 'name'),
Tables\Filters\TernaryFilter::make('is_visible'),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}The badge() modifier on stock with conditional colors is a great example of how Filament keeps the API ergonomic. With one expression, low stock turns yellow and out-of-stock turns red.
Step 6: Add the Order Resource and Relation Manager
Orders are more complex because they have child line items. Generate the resource first.
php artisan make:filament-resource Order --generateNow generate a relation manager for order items. Relation managers are nested tables that appear on the parent resource detail page.
php artisan make:filament-relation-manager OrderResource items product_idThis scaffolds OrderResource/RelationManagers/ItemsRelationManager.php. Configure it to display order items inline.
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('product.name')
->columns([
Tables\Columns\TextColumn::make('product.name'),
Tables\Columns\TextColumn::make('quantity'),
Tables\Columns\TextColumn::make('unit_price')->money('USD'),
])
->headerActions([
Tables\Actions\CreateAction::make()
->form([
Forms\Components\Select::make('product_id')
->relationship('product', 'name')
->required(),
Forms\Components\TextInput::make('quantity')
->numeric()
->required(),
Forms\Components\TextInput::make('unit_price')
->numeric()
->required(),
]),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
]);
}Register the relation manager on the parent resource by adding it to the getRelations method of OrderResource.
public static function getRelations(): array
{
return [
RelationManagers\ItemsRelationManager::class,
];
}Now when you open an order, you will see an inline table for line items. You can add, edit, and remove rows without leaving the page.
Step 7: Custom Dashboard Widgets
The default Filament dashboard is empty. Let's add three widgets — a stats overview, a chart, and a table of recent orders.
php artisan make:filament-widget RevenueOverview --stats-overviewOpen app/Filament/Widgets/RevenueOverview.php and replace its body with real numbers.
class RevenueOverview extends BaseWidget
{
protected function getStats(): array
{
return [
Stat::make('Total revenue', '$' . number_format(Order::sum('total'), 2))
->description('All time')
->descriptionIcon('heroicon-m-arrow-trending-up')
->color('success'),
Stat::make('Orders this month', Order::whereMonth('created_at', now()->month)->count())
->description('Created in current month')
->color('primary'),
Stat::make('Active products', Product::where('is_visible', true)->count())
->color('warning'),
];
}
}Generate a chart widget for sales over time.
php artisan make:filament-widget SalesChart --chartIn SalesChart.php, return a dataset that aggregates orders per day for the last 30 days.
protected function getData(): array
{
$data = Trend::model(Order::class)
->between(start: now()->subDays(30), end: now())
->perDay()
->sum('total');
return [
'datasets' => [
[
'label' => 'Daily revenue',
'data' => $data->map(fn ($value) => $value->aggregate),
'fill' => true,
],
],
'labels' => $data->map(fn ($value) => $value->date),
];
}
protected function getType(): string
{
return 'line';
}The Trend helper comes from flowframe/laravel-trend, a tiny package that turns time-series queries into a single line. Install it now.
composer require flowframe/laravel-trendReload the dashboard. You will see real revenue stats and a sparkline chart. Add the widgets to the dashboard by editing AdminPanelProvider.
->widgets([
Widgets\RevenueOverview::class,
Widgets\SalesChart::class,
])Step 8: Role-Based Access Control
A real admin panel needs roles and permissions. Install the spatie package, which integrates seamlessly with Filament through a community plugin.
composer require spatie/laravel-permission
composer require bezhansalleh/filament-shield
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrate
php artisan shield:install adminThe Shield installer creates a Roles resource, generates default permissions, and updates your User model to use the HasRoles trait. Run the permission generator to create per-resource permissions.
php artisan shield:generate --allOpen the panel and you will see a new Roles section. Create a Manager role, assign view and edit permissions for products and orders, then create a second admin user assigned to that role. Sign in as that user and verify the sidebar only shows the resources they can access.
For production, gate the panel itself with the Filament canAccessPanel interface on the User model.
class User extends Authenticatable implements FilamentUser
{
public function canAccessPanel(Panel $panel): bool
{
return $this->hasAnyRole(['super_admin', 'manager']);
}
}Step 9: Multi-Tenancy
Filament v3 introduced first-class multi-tenancy. Each tenant becomes a scope in the URL and a constraint on every query. We will model tenants as Shop entities.
php artisan make:model Shop -mDefine the migration and the Shop model with a relationship to users.
Schema::create('shops', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->timestamps();
});
Schema::create('shop_user', function (Blueprint $table) {
$table->foreignId('shop_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->primary(['shop_id', 'user_id']);
});Add a shop_id column to products and orders, then enable tenancy in AdminPanelProvider.
return $panel
->id('admin')
->path('admin')
->tenant(Shop::class, slugAttribute: 'slug')
->tenantRegistration(RegisterShop::class);Implement the HasTenants interface on the User model so Filament knows which shops a user belongs to.
class User extends Authenticatable implements FilamentUser, HasTenants
{
public function getTenants(Panel $panel): Collection
{
return $this->shops;
}
public function canAccessTenant(Model $tenant): bool
{
return $this->shops()->whereKey($tenant)->exists();
}
}Filament will now show a tenant switcher in the top bar. URLs become /admin/{shopSlug}/products, and queries are automatically scoped to the active tenant.
Step 10: File Storage and Backups
Configure local file storage for product images. In config/filesystems.php, ensure the public disk is symlinked.
php artisan storage:linkFor production, switch the disk to S3 or a compatible provider such as Cloudflare R2. Add credentials to .env and change the FileUpload component to use the configured disk.
Forms\Components\FileUpload::make('image')
->image()
->disk('s3')
->visibility('public')
->directory('products')Install the Spatie backup package so you can sleep at night.
composer require spatie/laravel-backup
php artisan vendor:publish --provider="Spatie\Backup\BackupServiceProvider"Schedule a nightly backup in app/Console/Kernel.php.
$schedule->command('backup:run')->dailyAt('02:00');
$schedule->command('backup:clean')->dailyAt('02:30');Step 11: Production Deployment
For production we recommend Laravel Forge or a Docker-based deploy on Coolify. Either way, the workflow is the same. Pull the code, install Composer and npm dependencies, build assets, and run migrations.
A typical deploy script looks like this.
git pull origin main
composer install --no-dev --optimize-autoloader
npm ci && npm run build
php artisan migrate --force
php artisan filament:cache-components
php artisan icons:cache
php artisan optimize
php artisan queue:restartThe filament:cache-components command pre-compiles Blade views for Filament components, cutting first-request latency by hundreds of milliseconds. Run optimize to cache config and routes.
Configure a queue worker via Supervisor or systemd, since Filament uses queued notifications and bulk actions can be deferred.
[program:noqtashop-worker]
command=php /srv/noqtashop/artisan queue:work --tries=3 --timeout=90
autostart=true
autorestart=true
user=deploy
numprocs=2Testing Your Implementation
Filament integrates with Laravel's HTTP testing helpers. Write a quick smoke test to ensure the dashboard loads.
test('admin dashboard loads for authenticated users', function () {
$user = User::factory()->create();
$user->assignRole('super_admin');
$this->actingAs($user)
->get('/admin')
->assertOk();
});For each resource, write a Pest test that creates a record and asserts it appears in the table.
test('manager can create a product', function () {
$manager = User::factory()->create();
$manager->assignRole('manager');
$this->actingAs($manager);
Livewire::test(CreateProduct::class)
->fillForm([
'name' => 'Demo Hoodie',
'slug' => 'demo-hoodie',
'sku' => 'DEMO-1',
'price' => 49.99,
'category_id' => Category::factory()->create()->id,
])
->call('create')
->assertHasNoFormErrors();
$this->assertDatabaseHas('products', ['sku' => 'DEMO-1']);
});Troubleshooting
A few issues come up often when shipping Filament panels.
Slow first request after deploy. Run filament:cache-components and view:cache as part of the deploy script.
File uploads silently fail. Confirm php.ini has upload_max_filesize and post_max_size set high enough, and that your S3 bucket policy allows public reads.
Tenant switcher missing. Verify the User model implements HasTenants and that getTenants returns a non-empty collection.
Permissions feel inconsistent. Run php artisan shield:generate --all after every new resource so policies stay in sync.
Next Steps
You now have a production-ready admin panel. From here, you can extend it in many directions.
- Add the Filament Notifications package for real-time toasts and a bell menu
- Integrate a media library plugin such as
awcodes/filament-curatorfor centralized asset management - Build custom pages for KPIs that do not map cleanly to a single resource
- Wire up a public Livewire storefront that consumes the same models
- Migrate file storage to Cloudflare R2 for cheap, fast media delivery
If you are coming from Next.js, you will appreciate how much Filament gives you for free. If you are extending an existing Laravel app, Filament drops in next to your routes without rewriting anything.
Conclusion
In one afternoon, we built a complete admin panel with resources, dashboards, permissions, and multi-tenancy. Filament v3 has reached the level of polish where it is no longer a niche choice — it is the default answer for Laravel admin work in 2026. The combination of Livewire 3, a thoughtful component API, and a passionate community means you spend your time on real product features instead of yak-shaving the chrome around them.
Use this tutorial as a foundation, deploy your own version, and explore the Filament plugin ecosystem. The community plugins page lists hundreds of open-source extensions covering everything from Stripe billing to OAuth integrations. Pick the ones you need, ship fast, and let your admin panel keep up with your product.