FilamentPHP Customization Guide
This document describes the FilamentPHP 3 implementation in Tenanto.
Overview
Tenanto uses two separate Filament panels:
| Panel | Path | Purpose | Auth |
|---|---|---|---|
| Admin | /admin |
System administration | System admins only |
| Tenant | /app |
Tenant management | Tenant users |
┌─────────────────────────────────────────────────┐
│ Filament Panels │
├─────────────────────────────────────────────────┤
│ │
│ Admin Panel (admin.tenanto.com/admin) │
│ ├── TenantResource - Manage tenants │
│ ├── UserResource - Manage all users │
│ └── LicenseResource - License management │
│ │
│ Tenant Panel (acme.tenanto.com/app) │
│ ├── ProjectResource - Projects CRUD │
│ ├── TaskResource - Tasks CRUD │
│ └── Dashboard widgets - Overview stats │
│ │
└─────────────────────────────────────────────────┘
Panel Configuration
Admin Panel
Located at app/Providers/Filament/AdminPanelProvider.php:
final class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('admin')
->path('admin')
->login()
->brandName('Tenanto Admin')
->colors([
'primary' => Color::Indigo,
'gray' => Color::Slate,
])
->discoverResources(
in: app_path('Filament/Admin/Resources'),
for: 'App\\Filament\\Admin\\Resources'
)
->navigationGroups([
'Tenancy',
'System',
]);
}
}
Tenant Panel
Located at app/Providers/Filament/TenantPanelProvider.php:
final class TenantPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->id('tenant')
->path('app')
->login()
->brandName(fn () => $this->getTenantName())
->colors([
'primary' => Color::Blue,
'gray' => Color::Slate,
])
->discoverResources(
in: app_path('Filament/Tenant/Resources'),
for: 'App\\Filament\\Tenant\\Resources'
)
->middleware([
// ... standard middleware
TenantMiddleware::class, // IMPORTANT!
])
->navigationGroups([
'Projects',
'Team Management',
'Settings',
]);
}
private function getTenantName(): string
{
try {
$tenant = app(TenantManagerInterface::class)->current();
return $tenant?->name ?? 'Tenant App';
} catch (\Throwable) {
return 'Tenant App';
}
}
}
Directory Structure
app/Filament/
├── Admin/
│ ├── Resources/
│ │ ├── TenantResource/
│ │ │ ├── Pages/
│ │ │ │ ├── ListTenants.php
│ │ │ │ ├── CreateTenant.php
│ │ │ │ ├── ViewTenant.php
│ │ │ │ └── EditTenant.php
│ │ │ └── RelationManagers/
│ │ │ └── SubscriptionsRelationManager.php
│ │ ├── TenantResource.php
│ │ ├── UserResource.php
│ │ └── LicenseResource.php
│ ├── Pages/
│ └── Widgets/
└── Tenant/
├── Resources/
│ ├── ProjectResource/
│ │ ├── Pages/
│ │ └── RelationManagers/
│ │ └── TasksRelationManager.php
│ ├── ProjectResource.php
│ └── TaskResource.php
├── Pages/
└── Widgets/
├── ProjectsOverviewWidget.php
└── RecentTasksWidget.php
Creating Resources
Tenant-Scoped Resource
For resources that belong to tenants:
// app/Filament/Tenant/Resources/ProjectResource.php
class ProjectResource extends Resource
{
protected static ?string $model = Project::class;
protected static ?string $navigationIcon = 'heroicon-o-folder';
protected static ?string $navigationGroup = 'Projects';
// Eager loading for performance (prevents N+1)
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->withCount([
'tasks',
'tasks as open_tasks_count' => fn ($q) =>
$q->whereIn('status', ['todo', 'in_progress', 'in_review']),
'tasks as completed_tasks_count' => fn ($q) =>
$q->where('status', 'done'),
]);
}
// Cached navigation badge
public static function getNavigationBadge(): ?string
{
$tenantId = app(TenantManagerInterface::class)->id();
if (!$tenantId) return null;
return Cache::remember(
"project_nav_badge_{$tenantId}",
now()->addMinutes(5),
fn () => (string) self::getModel()::active()->count()
);
}
public static function form(Form $form): Form
{
return $form->schema([
Forms\Components\Section::make('Project Details')
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255),
Forms\Components\Textarea::make('description')
->rows(3),
Forms\Components\ColorPicker::make('color')
->default('#3B82F6'),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\ColorColumn::make('color'),
Tables\Columns\TextColumn::make('open_tasks_count')
->label('Open Tasks'),
Tables\Columns\IconColumn::make('is_archived')
->boolean(),
])
->filters([
Tables\Filters\TernaryFilter::make('is_archived')
->label('Show Archived'),
])
->actions([
Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::make(),
]);
}
}
Admin Resource
For system-wide resources:
// app/Filament/Admin/Resources/TenantResource.php
class TenantResource extends Resource
{
protected static ?string $model = Tenant::class;
protected static ?string $navigationIcon = 'heroicon-o-building-office';
protected static ?string $navigationGroup = 'Tenancy';
// Admin resources don't have tenant scope
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->with(['subscriptions'])
->withCount('users');
}
public static function getNavigationBadge(): ?string
{
return Cache::remember(
'admin_tenant_nav_badge',
now()->addMinutes(5),
fn () => (string) self::getModel()::count()
);
}
}
Creating Widgets
Stats Widget
// app/Filament/Tenant/Widgets/ProjectsOverviewWidget.php
class ProjectsOverviewWidget extends BaseWidget
{
protected static ?int $sort = 1;
private const CACHE_MINUTES = 5;
protected function getStats(): array
{
$stats = $this->getCachedStats();
return [
Stat::make('Active Projects', $stats['active_projects'])
->icon('heroicon-o-folder')
->color('success'),
Stat::make('Open Tasks', $stats['open_tasks'])
->icon('heroicon-o-clipboard-document-list')
->color('info'),
Stat::make('Overdue', $stats['overdue_tasks'])
->icon('heroicon-o-exclamation-triangle')
->color($stats['overdue_tasks'] > 0 ? 'danger' : 'gray'),
];
}
private function getCachedStats(): array
{
$tenantId = app(TenantManagerInterface::class)->id();
return Cache::remember(
"dashboard_stats_{$tenantId}",
now()->addMinutes(self::CACHE_MINUTES),
fn () => [
'active_projects' => Project::active()->count(),
'open_tasks' => Task::open()->count(),
'overdue_tasks' => Task::overdue()->count(),
]
);
}
}
Table Widget
// app/Filament/Tenant/Widgets/RecentTasksWidget.php
class RecentTasksWidget extends BaseWidget
{
protected static ?int $sort = 2;
protected int|string|array $columnSpan = 'full';
public function table(Table $table): Table
{
return $table
->query(
Task::query()
->with('project') // Eager load!
->where('assigned_to', auth()->id())
->open()
->orderByPriority()
->limit(5)
)
->columns([
Tables\Columns\TextColumn::make('title'),
Tables\Columns\TextColumn::make('project.name'),
Tables\Columns\BadgeColumn::make('priority')
->colors([
'gray' => 'low',
'info' => 'medium',
'warning' => 'high',
'danger' => 'urgent',
]),
])
->actions([
Tables\Actions\Action::make('complete')
->action(fn (Task $record) => $record->markAsDone()),
]);
}
}
Relation Managers
Tasks Relation Manager
// app/Filament/Tenant/Resources/ProjectResource/RelationManagers/TasksRelationManager.php
class TasksRelationManager extends RelationManager
{
protected static string $relationship = 'tasks';
public function form(Form $form): Form
{
return $form->schema([
Forms\Components\TextInput::make('title')
->required(),
Forms\Components\Select::make('status')
->options(TaskStatus::options())
->default('todo'),
Forms\Components\Select::make('priority')
->options(TaskPriority::options())
->default('medium'),
Forms\Components\Select::make('assigned_to')
->relationship('assignee', 'name')
->searchable(),
Forms\Components\DatePicker::make('due_date'),
]);
}
public function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('title'),
Tables\Columns\BadgeColumn::make('status'),
Tables\Columns\TextColumn::make('assignee.name'),
])
->headerActions([
Tables\Actions\CreateAction::make(),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
]);
}
}
Authorization
Policy-Based Authorization
Filament automatically uses Laravel policies:
class ProjectResource extends Resource
{
// Filament calls these policy methods automatically:
// - viewAny() for listing
// - create() for create button
// - update() for edit button
// - delete() for delete button
}
Custom Authorization
For custom actions:
Tables\Actions\Action::make('archive')
->action(fn (Project $record) => $record->archive())
->visible(fn (Project $record): bool =>
auth()->user()->can('archive', $record)
)
->requiresConfirmation(),
Navigation Visibility
public static function canViewNavigation(): bool
{
return auth()->user()->can('viewAny', Project::class);
}
Customization
Custom Page
// app/Filament/Tenant/Pages/BillingSettings.php
class BillingSettings extends Page
{
protected static ?string $navigationIcon = 'heroicon-o-credit-card';
protected static ?string $navigationGroup = 'Settings';
protected static string $view = 'filament.tenant.pages.billing-settings';
public static function canAccess(): bool
{
return auth()->user()->can('manage billing');
}
}
Custom Form Component
Forms\Components\Select::make('assigned_to')
->label('Assignee')
->options(function () {
// Only show users from current tenant
$tenantId = app(TenantManagerInterface::class)->id();
return User::where('tenant_id', $tenantId)
->pluck('name', 'id');
})
->searchable()
->preload(),
Custom Table Column
Tables\Columns\TextColumn::make('completion_percentage')
->label('Progress')
->state(function (Project $record): string {
// Use eager loaded counts
$total = $record->tasks_count ?? 0;
$completed = $record->completed_tasks_count ?? 0;
if ($total === 0) return '0%';
return round(($completed / $total) * 100) . '%';
})
->badge()
->color(fn (string $state): string =>
(int) $state >= 100 ? 'success' : 'warning'
),
Performance Optimization
Eager Loading
Always use getEloquentQuery() for eager loading:
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->with(['project', 'assignee'])
->withCount('tasks');
}
Cached Navigation Badges
public static function getNavigationBadge(): ?string
{
$tenantId = app(TenantManagerInterface::class)->id();
if (!$tenantId) return null;
return Cache::remember(
"badge_key_{$tenantId}",
now()->addMinutes(5),
fn () => (string) self::getModel()::count()
);
}
Using withCount Instead of Accessors
// CORRECT: Efficient
->withCount([
'tasks',
'tasks as open_tasks_count' => fn ($q) => $q->open(),
])
// WRONG: N+1 queries
Tables\Columns\TextColumn::make('open_tasks_count')
->state(fn (Project $record) => $record->tasks()->open()->count()),
Testing
Resource Tests
use function Pest\Livewire\livewire;
it('can list projects', function () {
$user = User::factory()->create();
$tenant = $user->tenant;
Project::factory()
->for($tenant)
->count(3)
->create();
actingAs($user);
setCurrentTenant($tenant);
livewire(ListProjects::class)
->assertSuccessful()
->assertCanSeeTableRecords(
Project::where('tenant_id', $tenant->id)->get()
);
});
it('can create project', function () {
$user = User::factory()->create();
$user->assignRole('owner');
actingAs($user);
setCurrentTenant($user->tenant);
livewire(CreateProject::class)
->fillForm([
'name' => 'Test Project',
'description' => 'Description',
])
->call('create')
->assertHasNoFormErrors();
expect(Project::where('name', 'Test Project')->exists())->toBeTrue();
});
Branding
Custom Logo
->brandLogo(asset('images/logo.svg'))
->darkModeBrandLogo(asset('images/logo-dark.svg'))
Custom Colors
->colors([
'primary' => Color::hex('#1f2937'),
'danger' => Color::Red,
'success' => Color::Green,
])
Per-Tenant Branding
->brandName(fn (): string => $this->getTenantName())
->colors([
'primary' => fn (): array => $this->getTenantColor(),
])
private function getTenantColor(): array
{
$tenant = app(TenantManagerInterface::class)->current();
$hex = $tenant?->settings['brand_color'] ?? '#3B82F6';
return Color::hex($hex);
}
Troubleshooting
Resource Not Showing
-
Check namespace matches directory:
app/Filament/Tenant/Resources/ProjectResource.php namespace App\Filament\Tenant\Resources; -
Verify discovery path in panel provider:
->discoverResources( in: app_path('Filament/Tenant/Resources'), for: 'App\\Filament\\Tenant\\Resources' )
Tenant Data Leaking
-
Ensure
TenantMiddlewareis in middleware stack:->middleware([ // ... other middleware TenantMiddleware::class, ]) -
Check model uses
BelongsToTenanttrait
Permission Denied
-
Clear permission cache:
php artisan permission:cache-reset -
Verify policy is registered in
AuthServiceProvider