A client's operations screen needed three unrelated metric panels, a queue-depth monitor, and a stale-import warning. None of it hung off a single Eloquent model, so a resource List page was the wrong shape entirely. Filament v5 custom pages are the escape hatch — and because v5 runs on Livewire 4, every widget you drop on one is just a Livewire component you already know how to write.
When a custom page beats a resource#
Resources are for CRUD against one model. The moment your screen is a report, a control panel, or a cross-model summary, you're fighting the abstraction.
I reach for a resource page when the answer to "what record am I editing?" is obvious. If the screen shows five numbers from four tables, that's a custom page. If it's really one model with different views of the same records, you probably want status tabs on a Filament v5 list page instead — cheaper and it keeps the table tooling.
Filament v5 requires PHP 8.2+, Laravel 11.28+, Livewire 4.0+ and Tailwind CSS 4.0+. Beyond the Livewire 4 dependency there are no functional changes from v4, so if you're still on the older major the Filament v4 to v5 upgrade is a short trip.
Scaffolding a Filament v5 custom page#
One Artisan command:
php artisan make:filament-page OperationsDashboard
It asks which panel to create the page in and whether the page belongs to a resource — answer no to the resource question for a standalone page. You get two files: a class in app/Filament/Pages and a Blade view in resources/views/filament/pages.
The generated class is deliberately bare:
<?php
namespace App\Filament\Pages;
use BackedEnum;
use Filament\Pages\Page;
use Filament\Support\Icons\Heroicon;
class OperationsDashboard extends Page
{
// Note: $view is a non-static protected property in v5.
protected string $view = 'filament.pages.operations-dashboard';
protected static string | BackedEnum | null $navigationIcon = Heroicon::OutlinedChartBar;
protected static ?string $navigationLabel = 'Operations';
// Without this, the slug is derived from the class name: /operations-dashboard
protected static ?string $slug = 'operations';
}
The view is equally bare, and it's yours:
<x-filament-panels::page>
{{-- Page content --}}
</x-filament-panels::page>
Page classes are full-page Livewire components with panel extras bolted on — InteractsWithActions, header actions, sub-navigation. Anything you can do in a Livewire component, you can do here.
Lock the page down with canAccess(), which controls both the navigation item and direct URL access:
public static function canAccess(): bool
{
return auth()->user()->can('viewOperations');
}
Registering widgets on a Filament v5 custom page#
Widgets slot in above and below your page content. Return them from getHeaderWidgets() or getFooterWidgets() — both are protected on the parent class, so declare yours the same way:
use App\Filament\Widgets\QueueDepthWidget;
use App\Filament\Widgets\StaleImportsWidget;
protected function getHeaderWidgets(): array
{
return [
QueueDepthWidget::class,
];
}
protected function getFooterWidgets(): array
{
return [
StaleImportsWidget::class,
];
}
Header widgets render between the page heading and your content; footer widgets render below it. Both sections hide themselves entirely when the resolved widget list is empty, so there's no stray whitespace if every widget fails its canView() check.
Building the widget itself is the same command you'd use for a dashboard widget:
php artisan make:filament-widget QueueDepth
It prompts for a type — Custom, Chart, Stats overview, or Table. A custom widget is a Livewire component plus a Blade view:
<?php
namespace App\Filament\Widgets;
use Filament\Widgets\Widget;
use Illuminate\Support\Facades\Queue;
class QueueDepthWidget extends Widget
{
protected string $view = 'filament.widgets.queue-depth-widget';
// 1–12, or 'full'. Defaults to 1 — easy to forget.
protected int | string | array $columnSpan = 'full';
public string $queue = 'default';
protected function getViewData(): array
{
return [
'depth' => Queue::size($this->queue),
];
}
}
For the metric-panel flavour, StatsOverviewWidget with Stat::make() gives you the familiar card row — I walked through that in detail in stats overview widgets with trend sparklines, and the API is unchanged in v5.
Passing data to widgets with make()#
Registering a bare class string is fine when the widget knows how to fetch its own data. When it doesn't, wrap it in make() and hand it an array of Livewire properties:
use App\Filament\Widgets\QueueDepthWidget;
protected function getHeaderWidgets(): array
{
return [
QueueDepthWidget::make([
'queue' => 'imports',
]),
QueueDepthWidget::make([
'queue' => 'notifications',
]),
];
}
Widget::make() returns a WidgetConfiguration — a tiny value object holding the class name and the properties. Filament mounts each one as a Livewire component and keys it by class name and array index, which is why registering the same widget class twice with different props works without a key collision.
Those keys map onto public properties on the widget class. public string $queue above receives 'imports', and inside the widget you read it as $this->queue.
If every widget on the page needs the same value, skip the repetition and use getWidgetData():
public function getWidgetData(): array
{
return [
'tenantId' => Filament::getTenant()?->getKey(),
];
}
Merge order matters: getWidgetData() is spread first, then per-widget make() properties. A property set in both places takes the make() value.
Laying out Filament v5 custom page widgets in a grid#
Here's the trap that cost me twenty minutes. Every tutorial says "override getColumns()" — and on a custom page, that does nothing at all.
getColumns() is defined on Filament\Pages\Dashboard, not on Filament\Pages\Page. A custom page builds its widget grid from getHeaderWidgetsColumns() and getFooterWidgetsColumns(), which both default to 2:
public function getHeaderWidgetsColumns(): int | array
{
return 3;
}
public function getFooterWidgetsColumns(): int | array
{
return 1;
}
Pass an array to vary the column count by Tailwind breakpoint:
public function getHeaderWidgetsColumns(): int | array
{
return [
'md' => 4,
'xl' => 5,
];
}
That pairs with each widget's own $columnSpan, which accepts the same shape:
protected int | string | array $columnSpan = [
'md' => 2,
'xl' => 3,
];
Remember $columnSpan defaults to 1. On a two-column grid, a single widget sits in the left half and looks broken until you set 'full'.
Embedding a plain Livewire component on the page#
Widgets are Livewire components, and the reverse holds: any Livewire component can live on a Filament page. Sometimes you don't want widget semantics — no grid, no canView(), no $columnSpan — you just want your component rendered inside the page body.
Drop it straight into the page's Blade view:
<x-filament-panels::page>
@livewire(\App\Livewire\ImportProgress::class, ['batchId' => $this->batchId])
</x-filament-panels::page>
Or, if you'd rather keep the layout declarative and out of Blade, use the schema component Filament itself uses internally:
use Filament\Schemas\Components\Livewire;
use Filament\Schemas\Schema;
public function content(Schema $schema): Schema
{
return $schema
->components([
Livewire::make(\App\Livewire\ImportProgress::class, [
'batchId' => $this->batchId,
])->lazy(),
]);
}
Both paths end at Livewire::mount(). The schema version gets you ->lazy() and the rest of the schema API for free, and it's how getHeaderWidgets() is implemented under the hood.
Gotchas and Edge Cases#
Widgets are lazy by default. Filament\Widgets\Widget uses the CanBeLazy trait, and $isLazy is true. Every widget renders a skeleton placeholder first, then hydrates on a second request. That's usually what you want, but it means a "cheap" widget still costs a round trip. Opt out per widget:
protected static bool $isLazy = false;
$columnSpan and $view are instance properties, $isLazy and $sort are static. Getting the modifier wrong produces a confusing PHP error rather than a Filament one. Copy the signatures from the parent class.
Typed properties with no default will fatal if you forget the prop. public string $queue; with no = 'default' throws "must not be accessed before initialization" the moment you register the widget as a bare class string instead of make(['queue' => ...]). Give every widget property a sensible default.
canView() is static and runs before mount. It can't read the widget's own properties — $this->queue doesn't exist yet. Authorise on the user, not on the widget's data.
Polling and lazy loading interact. If a widget both polls and lazy-loads, the first paint is a placeholder and the poll clock starts after hydration. I covered the tuning in chart widget polling and deferred loading; for the general Livewire 4 mechanics behind it, islands and lazy loading is the deeper dive.
Don't cache components while iterating. If you've run php artisan filament:cache-components, a newly created page or widget won't be discovered until you clear it.
Wrapping Up#
Scaffold the page, register widgets in getHeaderWidgets(), pass props with make(), and size the grid with getHeaderWidgetsColumns() — not getColumns(). That's the whole loop, and it takes about ten minutes once the naming stops surprising you.
From here, the natural next step is making the data on those widgets earn its place: column totals with table summarizers if you're surfacing tabular metrics, or wire:poll for live dashboard metrics if the numbers need to move on their own.
FAQ#
How do I create a custom page in Filament v5?
Run php artisan make:filament-page OperationsDashboard. The command asks which panel to use and whether the page belongs to a resource — answer no for a standalone page. It generates a page class in app/Filament/Pages extending Filament\Pages\Page, plus a Blade view in resources/views/filament/pages. The page is a full-page Livewire component and appears in the panel navigation automatically.
How do I add widgets to a custom Filament page?
Override the getHeaderWidgets() or getFooterWidgets() method on your page class and return an array of widget class names. Header widgets render above the page content and footer widgets render below it. Both methods are declared protected on the parent Page class, so match that visibility. Widgets whose static canView() returns false are filtered out before rendering.
How do I pass data to a Filament widget?
Use YourWidget::make(['key' => $value]) when registering it, instead of passing the bare class string. The array is mapped onto public Livewire properties on the widget class, so ['queue' => 'imports'] populates public string $queue and you read it as $this->queue. To share the same data with every widget on the page, override getWidgetData() on the page instead — per-widget make() properties override it.
Can I use a custom Livewire component on a Filament page?
Yes. Filament widgets are ordinary Livewire components, and the relationship works both ways. You can render any Livewire component in the page's Blade view with @livewire(\App\Livewire\Thing::class, [...]), or add it declaratively to the page's content() schema with Filament\Schemas\Components\Livewire::make(). The schema component is what Filament uses internally to mount widgets, and it supports ->lazy().
Does Filament v5 use Livewire 4?
Yes. Filament v5 was released specifically for Livewire v4 compatibility and requires Livewire 4.0+, alongside PHP 8.2+, Laravel 11.28+ and Tailwind CSS 4.0+. Apart from the Livewire dependency there are no functional changes from Filament v4, and the Filament team continues shipping features to both majors, so the upgrade is mostly a dependency bump.