Choosing Between FilamentPHP v3 Resources, Custom Pages, and Widgets for Admin Panels
A decision guide for choosing between FilamentPHP v3 Resources, Custom Pages, and Widgets. Covers constraints, trade-offs, authorization patterns, and concrete implementation examples for Laravel 10+ admin panels.
20 Jun 2026, 21:05 UTC

The Decision You're Facing
You're building an admin panel in FilamentPHP v3 for a Laravel 10+ application. You need to decide whether a new feature should be a Resource, a Custom Page, or a Widget. The wrong choice means rework: Resources give you CRUD for free but assume a single Eloquent model; Custom Pages give total control but require manual routing and table implementation; Widgets are composable dashboard pieces but can't have their own URLs.
Takeaway: Match the component to the workflow. Use Resources for domain entities with standard CRUD. Use Custom Pages for multi-model workflows, wizards, reports, or settings. Use Widgets for metrics, charts, or small interactive elements that live inside other pages.
Quick Comparison
| Aspect | Resource | Custom Page | Widget |
|---|---|---|---|
| Primary use case | Single-model CRUD (list, create, edit, delete, view) | Non-CRUD workflows: dashboards, wizards, reports, settings | Composable dashboard components: stats, charts, quick actions |
| Routing | Auto-generated (index, create, edit, view, delete) | Manual registration in panel provider | No routes; renders inside dashboard or resource pages |
| Navigation | Auto-added to sidebar | Manual via getNavigationGroup() / getNavigationLabel() | No navigation entry |
| Table features | Filtering, sorting, pagination, bulk actions built-in | Must compose Table component manually | Optional; use Table or StatsOverview widgets |
| Relationship Managers | Supported (HasMany, BelongsToMany, MorphToMany) | Not supported; build manually | Not supported |
| Authorization | Policy methods: viewAny, view, create, update, delete | canView() method or gate checks | canView() method |
| Testing helpers | assertCanRender(), assertTableRows(), etc. | Standard Livewire component tests | Standard Livewire component tests |
| Base class | Filament\Resources\Resource | Filament\Pages\Page | Filament\Widgets\Widget |
When to Choose a Resource
Resources are the default for any domain entity that maps 1:1 to an Eloquent model and needs standard admin operations. You get a fully featured table with filters, sorting, pagination, and bulk actions without writing a single line of table code. Create, edit, and view forms are generated from your Form schema. Relationship managers let you manage hasMany, belongsToMany, and morphToMany relations inline on the edit page.
Constraints: The Resource assumes a single model returned by getModel(). If your workflow spans multiple models (e.g., an order that touches payments, shipments, and inventory), a Resource forces you to pick one as "primary" and awkwardly shoehorn the rest.
Verification: Run php artisan make:filament-resource User --generate in a fresh panel. Inspect app/Filament/Resources/UserResource.php — the getModel() method returns User::class. The generated Pages directory contains ListUsers, CreateUser, EditUser, ViewUser. Check routes with php artisan route:list --path=admin; you'll see admin/users, admin/users/create, admin/users/{record}/edit, etc.
When to Choose a Custom Page
Custom Pages (extending Filament\Pages\Page) are the escape hatch when the workflow doesn't fit a single model. Common cases:
- Multi-model dashboards (e.g., an "Orders Overview" showing orders, payments, and shipments in separate tables)
- Wizards or multi-step forms (onboarding, checkout flows)
- Reports with complex filters and export logic
- Settings pages that don't map to a model
- Import/export workflows
You compose the page using Livewire components: Table, Form, Actions, Infolist. This gives full control but means you implement filtering, pagination, and bulk actions yourself if needed.
Routing: Register in your panel provider's pages() array:
protected function pages(): array
{
return [
\App\Filament\Pages\OrdersOverview::class,
];
}
The page class defines its own URL via getUrl() or the #[Page] attribute (v3.2+). Navigation label and group come from $navigationLabel, $navigationGroup, $navigationIcon properties or their getter methods.
Caution: Do not force CRUD-like features into a Custom Page. You lose the automatic table tooling that Resources provide. If you find yourself reimplementing a filterable, sortable, paginated table with bulk actions — use a Resource instead.
When to Choose a Widget
Widgets (extending Filament\Widgets\Widget) are not full pages. They render inside the dashboard (Dashboard::class) or within a Resource's header/footer via getHeaderWidgets() / getFooterWidgets(). Use them for:
- Metric cards (revenue, user count, conversion rate)
- Charts (LineChartWidget, BarChartWidget, etc.)
- Quick stats overviews (
StatsOverviewWidget) - Small interactive elements (recent activity feed, quick-create buttons)
Register in the panel provider:
protected function widgets(): array
{
return [
\App\Filament\Widgets\RevenueStats::class,
\App\Filament\Widgets\RecentOrders::class,
];
}
Limitation: Widgets cannot have their own URLs or navigation items. They are strictly presentational components within a parent page layout.
Concrete Decision Walkthrough
Scenario: You're building an admin panel for a SaaS application. You need to handle three features:
- User management — list, create, edit, delete, view users; manage their subscriptions and team memberships.
- Revenue dashboard — show MRR, churn, new signups, upgrade/downgrade trends with charts; filter by date range.
- Subscription plan settings — configure plan names, prices, features, trial days; no Eloquent model (stored in config or a key-value table).
Feature 1: User Management → Resource
Users are a single Eloquent model (App\Models\User) with standard CRUD needs. Subscriptions and team memberships are hasMany and belongsToMany relations — perfect for Relationship Managers.
// app/Filament/Resources/UserResource.php
class UserResource extends Resource
{
protected static ?string $model = User::class;
public static function getRelations(): array
{
return [
RelationManagers\SubscriptionsRelationManager::class,
RelationManagers\TeamsRelationManager::class,
];
}
}
Run php artisan make:filament-relation-manager UserResource subscriptions title --generate to scaffold the subscription manager. The edit page now shows a "Subscriptions" tab with its own table and create/edit forms.
Feature 2: Revenue Dashboard → Custom Page + Widgets
The dashboard needs multiple charts and metric cards with a shared date-range filter. This is a Custom Page (RevenueDashboard) that composes Widgets.
// app/Filament/Pages/RevenueDashboard.php
class RevenueDashboard extends Page
{
protected static ?string $navigationIcon = 'heroicon-o-chart-bar';
protected static string $view = 'filament.pages.revenue-dashboard';
public function getHeaderWidgets(): array
{
return [
MRRWidget::class,
ChurnRateWidget::class,
NewSignupsWidget::class,
DateRangeFilterWidget::class, // custom widget that emits events
];
}
}
The Blade view (resources/views/filament/pages/revenue-dashboard.blade.php) can include additional <x-filament::section> layouts with Table components for detailed breakdowns. Register in panel provider's pages().
Feature 3: Subscription Plan Settings → Custom Page
No Eloquent model; settings live in config/billing.php or a settings table with key-value rows. A Custom Page with a Form component handles this cleanly.
// app/Filament/Pages/SubscriptionSettings.php
class SubscriptionSettings extends Page
{
protected static ?string $navigationIcon = 'heroicon-o-cog-6-tooth';
protected static string $view = 'filament.pages.subscription-settings';
public function getFormSchema(): array
{
return [
Section::make('Plans')->schema([
Repeater::make('plans')
->schema([
TextInput::make('name')->required(),
TextInput::make('price')->numeric()->required(),
TextInput::make('interval')->required()->default('month'),
Toggle::make('trial_enabled'),
TextInput::make('trial_days')->numeric()->visible(fn (Get $get) => $get('trial_enabled')),
KeyValue::make('features'),
])
->columns(3)
->addActionLabel('Add Plan'),
]),
];
}
}
The page handles saving to config/database in a save() action. No Resource needed — there's no list/index view, no individual record routes.
Authorization Patterns
Each component type handles authorization differently:
- Resource: Create a Policy (
php artisan make:policy UserPolicy --model=User), implementviewAny,view,create,update,delete. Register inAuthServiceProvider. Filament automatically checks these for each action. - Custom Page: Implement
public static function canView(): boolon the page class, or useGate::allows('view-revenue-dashboard')in the method. The page simply won't appear in navigation or be accessible if false. - Widget: Implement
public static function canView(): bool. The widget hides itself from the dashboard/resource layout when false.
Test authorization with a feature test:
// tests/Feature/Filament/UserResourceTest.php
public function test_admin_can_view_users()
{
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
->get('/admin/users')
->assertOk();
}
public function test_non_admin_cannot_view_users()
{
$user = User::factory()->create();
$this->actingAs($user)
->get('/admin/users')
->assertForbidden(); // or redirect, depending on panel config
}
Migration Notes from Filament v2
- v2 Resources map 1:1 to v3 Resources — class structure unchanged.
- v2 Pages become v3 Custom Pages. The
getTitle()method is removed; use$titleproperty orgetHeading()instead. - v2 Widgets remain compatible; v3 introduces
Widgetbase class changes (view property vsrender()method). Checkprotected static string $viewusage. - Panel registration order matters:
resources()beforepages()beforewidgets()affects navigation grouping.
Verification Checklist
After implementing, verify each component type works as expected:
- Resource: Visit
/admin/users— confirm table loads, filters work, create/edit/view/delete actions function. Check Relationship Manager tabs on edit page. - Custom Page: Visit the page URL — confirm navigation item appears, form/table renders, save actions persist data. Test
canView()with different user roles. - Widget: Visit dashboard — confirm widget renders in correct position. Test
canView()visibility. Verify widget also renders when added to a Resource'sgetHeaderWidgets(). - Routes: Run
php artisan route:list --path=admin. Resources show RESTful routes. Custom Pages show a single GET route. Widgets show no routes. - Tests: Run Resource testing helpers (
assertCanRender(),assertTableRows()). For Custom Pages and Widgets, write Livewire component tests usingLivewire::test().
Limitations to Keep in Mind
- Resources cannot natively handle multi-model list views. If you need a unified table joining orders + payments + shipments, use a Custom Page with a
Tablecomponent backed by a custom query or view model. - Widgets cannot accept route parameters. If you need a "User Revenue" widget that varies by user, pass the user via the parent Resource's
getHeaderWidgets()using a closure:fn () => UserRevenueWidget::make(['user' => $this->record]). - Custom Pages don't inherit Resource's breadcrumb generation. Implement
getBreadcrumbs()manually if needed. - Relationship Managers require the parent Resource's
getModel()to return the correct Eloquent class. If you overridegetModel()dynamically, Relationship Managers may break.
Final Decision Framework
Ask these three questions in order:
- Does this map to a single Eloquent model with standard CRUD? → Resource
- Does this need its own URL and navigation entry but isn't single-model CRUD? → Custom Page
- Is this a metric, chart, or small interactive element that lives inside another page? → Widget
If you answer "yes" to multiple, compose: a Custom Page can contain Widgets; a Resource can embed Widgets in its header/footer; a Custom Page can embed multiple Table components for different models.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.