Tame a Bloated Filament v5 Sidebar with Clusters and Sub-Navigation

Retrofit filament clusters onto an existing v5 panel: discoverClusters, moving resources in, the URL prefix that breaks hardcoded links, and sub-navigation.

Steven Richardson
Steven Richardson
· 11 min read

The panel that shipped with six resources has twenty-eight now, and the sidebar is a scroll bar. Navigation groups make it look organised without reducing the item count, so eventually everyone finds clusters in the docs — and then discovers that a cluster changes URLs, rewrites breadcrumbs, and implies a directory reshuffle nobody scoped.

This is the retrofit path: existing resources, existing links, existing tests. Every API below is verified against Filament v5.7.8 on Laravel 13. Almost every result you'll find for filament clusters is v3 content where the docs live under panels/ and the icon is a plain string, so check the version before you copy anything.

Decide whether you need a navigation group or a cluster#

Pick the structure before you write code, because one of these is free and reversible and the other is not. A navigation group adds a heading in the sidebar and nothing else. A cluster is structural: it collapses members behind one nav item, prefixes their routes, and injects a shared sub-navigation into every page inside it.

Navigation group Cluster
Sidebar items One per resource, under a heading One, for the whole cluster
URLs Unchanged Prefixed with the cluster slug
Breadcrumbs Unchanged Cluster name prepended
Sub-navigation None On every member page
Nesting Groups can't nest Clusters can't nest
Retrofit cost One property per resource Property, directory move, URL audit

My rule of thumb: cluster when the resources form a destination users navigate around inside — Settings, Billing configuration, Content — and group when they're merely adjacent in the alphabet. Twenty-eight scattered resources with no relationship between them want groups. Six resources that all answer "how is this tenant configured?" want a cluster.

The two combine, which is the part that actually tames a big panel: a cluster's own nav item honours $navigationGroup, so you can put clusters inside groups and collapse twenty-eight items down to eight.

Register cluster discovery in the panel provider#

Add discoverClusters() alongside the resource and page discovery calls in your panel provider. Do this first. A cluster class that exists but is never discovered produces exactly no error and no nav item, and it is the single most common reason a cluster "doesn't work".

// app/Providers/Filament/AdminPanelProvider.php

public function panel(Panel $panel): Panel
{
    return $panel
        ->id('admin')
        ->path('admin')
        ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
        ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
        // Without this, cluster classes are never registered and never appear.
        ->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters');
}

In a multi-panel app, a cluster belongs to whichever panel discovers it. A resource whose $cluster points at a class the panel doesn't know about is a genuinely confusing failure — the resource vanishes from the main navigation (because it thinks it lives in a cluster) while the cluster nav item never renders. If you run separate admin and tenant panels, add discoverClusters() to both or keep the cluster's members in one panel.

Generate the cluster class and set its navigation icon#

Run the generator, then set the icon immediately — the default is deliberately generic. Note the v5 type signature: string | BackedEnum | null with a Filament\Support\Icons\Heroicon case, not the v3/v4 'heroicon-o-squares-2x2' string that every older tutorial shows.

php artisan make:filament-cluster Settings

That writes app/Filament/Clusters/Settings/SettingsCluster.php:

<?php

namespace App\Filament\Clusters\Settings;

use BackedEnum;
use Filament\Clusters\Cluster;
use Filament\Support\Icons\Heroicon;

class SettingsCluster extends Cluster
{
    protected static string | BackedEnum | null $navigationIcon = Heroicon::OutlinedCog6Tooth;
}

Every navigation property available to a resource works here and applies to the cluster's single nav item. This is the combination worth writing down — a cluster sitting inside a navigation group, sorted where you want it:

class SettingsCluster extends Cluster
{
    protected static string | BackedEnum | null $navigationIcon = Heroicon::OutlinedCog6Tooth;

    protected static ?string $navigationLabel = 'Configuration';

    // The cluster's own item lives inside a group, alongside other clusters.
    protected static ?string $navigationGroup = 'Administration';

    protected static ?int $navigationSort = 90;
}

Move existing resources into the cluster directory#

Move the files with git mv so history follows, then fix the namespaces. Filament recommends mirroring the cluster name in the directory tree, and while only the $cluster property is load-bearing, matching the convention is what makes the generators offer to scaffold inside the cluster later.

The target structure, straight from the docs:

app/Filament
+-- Clusters
|   +-- Settings
|   |   +-- SettingsCluster.php
|   |   +-- Pages
|   |   |   +-- ManageBranding.php
|   |   |   +-- ManageNotifications.php
|   |   +-- Resources
|   |   |   +-- Colors
|   |   |   |   +-- ColorResource.php
|   |   |   |   +-- Pages
|   |   |   |   |   +-- CreateColor.php
|   |   |   |   |   +-- EditColor.php
|   |   |   |   |   +-- ListColors.php

The moves for an existing ColorResource and a custom Livewire-widget page you already have:

mkdir -p app/Filament/Clusters/Settings/Resources
git mv app/Filament/Resources/Colors app/Filament/Clusters/Settings/Resources/Colors
git mv app/Filament/Pages/ManageBranding.php app/Filament/Clusters/Settings/Pages/ManageBranding.php

# Rewrite the namespaces and every import that referenced the old ones.
grep -rl 'App\\Filament\\Resources\\Colors' app tests \
  | xargs sed -i '' 's/App\\Filament\\Resources\\Colors/App\\Filament\\Clusters\\Settings\\Resources\\Colors/g'
grep -rl 'App\\Filament\\Pages\\ManageBranding' app tests \
  | xargs sed -i '' 's/App\\Filament\\Pages\\ManageBranding/App\\Filament\\Clusters\\Settings\\Pages\\ManageBranding/g'

composer dump-autoload

A Spatie-backed settings page is the archetypal first cluster member — it's already conceptually "settings", it just never had anywhere to live in the sidebar.

Assign the cluster property on each resource and page#

Add one property to every resource and custom page that belongs in the cluster. This is the only thing that actually creates membership; the directory move above is cosmetic.

namespace App\Filament\Clusters\Settings\Resources\Colors;

use App\Filament\Clusters\Settings\SettingsCluster;
use Filament\Resources\Resource;

class ColorResource extends Resource
{
    protected static ?string $model = Color::class;

    protected static ?string $cluster = SettingsCluster::class;
}

Watch the import path. The docs show use App\Filament\Clusters\SettingsCluster; in the membership example but generate the class into App\Filament\Clusters\Settings\SettingsCluster — with the recommended directory structure the namespace has the cluster segment in it. Copying the docs verbatim gives you a class-not-found on a name that looks right.

Custom pages take the same property:

namespace App\Filament\Clusters\Settings\Pages;

use App\Filament\Clusters\Settings\SettingsCluster;
use Filament\Pages\Page;

class ManageBranding extends Page
{
    protected static ?string $cluster = SettingsCluster::class;
}

Once a cluster exists, make:filament-resource and make:filament-page ask whether to create inside a cluster directory and set $cluster for you. New members are free; the retrofit above is the only manual part.

Fix the URLs that the cluster prefix just changed#

Audit for hardcoded panel paths now, before anyone clicks a dead link. Every member's routes gain the cluster prefix, so a path you typed by hand in Blade, a notification, or a mail template is now a 404.

Before After
/admin/colors /admin/settings/colors
/admin/colors/3/edit /admin/settings/colors/3/edit
/admin/manage-branding /admin/settings/manage-branding

Find them:

# Hardcoded panel paths in Blade, mail and notification templates.
rg -n "['\"\(]/admin/" resources app --glob '!vendor'

Then swap each one for generated URLs, which absorb the prefix change silently:

{{-- Before: breaks the moment ColorResource joins a cluster --}}
<a href="/admin/colors">Colours</a>

{{-- After: Filament resolves the cluster prefix for you --}}
<a href="{{ \App\Filament\Clusters\Settings\Resources\Colors\ColorResource::getUrl('index') }}">
    Colours
</a>

<a href="{{ \App\Filament\Clusters\Settings\Resources\Colors\ColorResource::getUrl('edit', ['record' => $color]) }}">
    Edit {{ $color->name }}
</a>

<a href="{{ \App\Filament\Clusters\Settings\Pages\ManageBranding::getUrl() }}">Branding</a>

For links you can't edit — a bookmark, an email sent last month, a QR code on a printed sheet — leave a redirect in place for a release or two:

// routes/web.php
Route::redirect('/admin/colors', '/admin/settings/colors', 301);

Choose a sub-navigation position for the whole cluster#

Set $subNavigationPosition once on the cluster and every member page inherits it, rather than repeating the property on eight classes. Individual pages can still override it when one genuinely needs to differ.

use Filament\Pages\Enums\SubNavigationPosition;

class SettingsCluster extends Cluster
{
    protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::End;
}

Start is the default and puts the sub-navigation in a left rail — the right choice for six or more members, because the list stays readable as it grows. End moves that rail to the right, which reads well when the page content is a wide table and you want the eye to land on data first. Top renders the sub-navigation as tabs above the content:

protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Top;

Tabs look best at three or four members and start wrapping badly beyond that. They're also the position that reads most like a settings screen, which is usually what a cluster is.

Customise the cluster breadcrumb#

Override the breadcrumb when the cluster's class name isn't what you want users to read. The cluster name lands in the breadcrumbs of every member page and links back to the first member, so a class called SettingsCluster otherwise shows up as "Settings" whether or not that's your product's word for it.

class SettingsCluster extends Cluster
{
    protected static ?string $clusterBreadcrumb = 'Configuration';
}

For a translated or tenant-dependent label, use the method form instead:

public static function getClusterBreadcrumb(): string
{
    return __('filament/clusters/settings.breadcrumb');
}

Hide the sub-navigation or the cluster conditionally#

Turn the sub-navigation off when the cluster exists for URL grouping rather than for navigation. Set the property for a permanent decision, or override the method when it depends on a feature flag or the current user.

class SettingsCluster extends Cluster
{
    protected static bool $shouldRegisterSubNavigation = false;
}
public static function shouldRegisterSubNavigation(): bool
{
    return Feature::active('unified-settings');
}

Authorization is the part worth testing rather than assuming. A cluster's nav item links to the first member, so if a user's policies deny them that particular resource while allowing a later one, the cluster item points at a 403. Keep the cluster's landing member the most permissive one, and gate members with canAccess() or policies rather than hiding nav items by hand — the policy and permission setup for Filament v5 covers the resource side. In a team-scoped panel, check the cluster landing page as each role before shipping.

Test the cluster routes and authorization with Pest#

Write the tests against getUrl(), never against literal paths — that discipline is what makes the next cluster move a non-event. Any existing test that hits /admin/colors as a string is already broken; a test that calls ColorResource::getUrl('index') passed before the move and passes after it.

use App\Filament\Clusters\Settings\Pages\ManageBranding;
use App\Filament\Clusters\Settings\Resources\Colors\ColorResource;
use App\Filament\Clusters\Settings\Resources\Colors\Pages\ListColors;
use App\Models\User;

it('resolves clustered resource URLs with the cluster prefix', function () {
    expect(ColorResource::getUrl('index', isAbsolute: false))
        ->toBe('/admin/settings/colors');
});

it('renders the clustered resource index for an authorised user', function () {
    $this->actingAs(User::factory()->admin()->create())
        ->get(ColorResource::getUrl('index'))
        ->assertOk();
});

it('renders the clustered custom page', function () {
    $this->actingAs(User::factory()->admin()->create());

    Livewire::test(ListColors::class)->assertOk();

    $this->get(ManageBranding::getUrl())->assertOk();
});

it('forbids the cluster landing page for a user without access', function () {
    $this->actingAs(User::factory()->create()) // no settings permission
        ->get(ColorResource::getUrl('index'))
        ->assertForbidden();
});

That last test is the one that earns its keep. It's the difference between "the nav item is hidden" and "the nav item is visible and lands on a 403", and only one of those is a bug your users will report. The broader patterns are in testing Filament v5 resources with Pest.

Sidestep the gotchas that outlast the retrofit#

Clusters don't nest. The Laracasts thread asking for a cluster inside a cluster exists because it's the obvious next thought once one cluster works, and the answer is no — put the cluster inside a navigation group instead, or model the hierarchy as nested parent-child resources where the relationship is actually data.

The cluster sub-navigation lists one item per member — per resource, per custom page — not one per resource page. An extra ListPendingColors page registered in a resource's getPages() won't show up there; that's the resource's own sub-navigation, and people have filed it as a cluster bug more than once.

Global search results resolve through the same URL generation as everything else, so clustered resources come back with prefixed URLs without any change. If yours don't, the resource is building its own result URLs by hand — worth checking if you've customised global search actions and result HTML.

And if you're arriving here mid-migration, do the version upgrade first. Clusters moved from panels/clusters to navigation/clusters and the icon property changed type between majors, so retrofitting clusters onto a half-upgraded panel means debugging two things at once — the v4 to v5 upgrade guide is the cleaner order.

FAQ#

What is a cluster in Filament?

A cluster is a hierarchical grouping in a Filament panel that collects related resources and custom pages behind a single navigation item. When you use one, the individual items disappear from the main navigation, every member page gains a shared sub-navigation UI, member URLs are prefixed with the cluster's slug, and the cluster's name appears in each member's breadcrumbs.

What is the difference between a Filament cluster and a navigation group?

A navigation group is purely visual — it adds a heading above a set of sidebar items that keep their own URLs, breadcrumbs and item count. A cluster is structural: it replaces those items with one, re-prefixes their routes, rewrites their breadcrumbs and adds sub-navigation. Groups are free and reversible; clusters change URLs, so they need a link audit. The two combine, and a cluster placed inside a navigation group is the most effective way to shrink a large sidebar.

Why is my Filament cluster not showing in the navigation?

In almost every case the panel provider is missing ->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters'). Without that call the cluster class is never registered, and Filament raises no error — the nav item simply doesn't render. The second most common cause is a resource whose $cluster property points at a cluster the panel doesn't discover, which also removes the resource from the main navigation.

How do I move an existing Filament resource into a cluster?

Set protected static ?string $cluster = YourCluster::class; on the resource — that alone creates membership. Then optionally git mv the resource into app/Filament/Clusters/{Cluster}/Resources/ and update its namespace to match Filament's recommended structure. Finally, audit for hardcoded panel paths, because the resource's URLs now carry the cluster prefix, and replace them with YourResource::getUrl() calls.

How do I show Filament cluster sub-navigation as tabs?

Set protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Top; on the cluster class, importing the enum from Filament\Pages\Enums\SubNavigationPosition. Setting it on the cluster applies it to every member page at once. Top renders the sub-navigation as tabs above the page content, which reads best with three or four members before the tabs start to wrap.

Do cluster URLs change and will my existing links break?

Yes — every resource and page in a cluster gets a new URL prefixed with the cluster's slug, so /admin/colors becomes /admin/settings/colors. Links generated through ColorResource::getUrl() or ManageBranding::getUrl() update automatically and need no changes. Anything hardcoded — a Blade href, a mail template, a bookmark, a test asserting a literal path — will 404, so grep for /admin/ before you deploy and leave 301 redirects for external links you can't edit.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.