T
Tenanto
Documentation / Filament

Filament

Updated Jan 25, 2026

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

  1. Check namespace matches directory:

    app/Filament/Tenant/Resources/ProjectResource.php
    namespace App\Filament\Tenant\Resources;
    
  2. Verify discovery path in panel provider:

    ->discoverResources(
        in: app_path('Filament/Tenant/Resources'),
        for: 'App\\Filament\\Tenant\\Resources'
    )
    

Tenant Data Leaking

  1. Ensure TenantMiddleware is in middleware stack:

    ->middleware([
        // ... other middleware
        TenantMiddleware::class,
    ])
    
  2. Check model uses BelongsToTenant trait

Permission Denied

  1. Clear permission cache:

    php artisan permission:cache-reset
    
  2. Verify policy is registered in AuthServiceProvider


Related Documentation