Labels and Navigation
Every resource derives a singular label, a plural label, and a sidebar entry from its model name. This page covers the declarations that change any of them, and how the sidebar entry is built — reach for it when a resource is called the wrong thing, sits in the wrong group, or should not be in the sidebar at all.
Nothing declared
use App\Models\BlogPost;
use PandaPanel\Resources\Resource;
final class BlogPostResource extends Resource
{
protected static string $model = BlogPost::class;
// ...
}2
3
4
5
6
7
8
9
| Question | Answer | Derived from |
|---|---|---|
BlogPostResource::label() | Blog Post | Str::headline(class_basename($model)) |
BlogPostResource::pluralLabel() | Blog Posts | Str::plural() of the label |
BlogPostResource::slug() | blog-posts | plural, kebab-cased, of the model basename |
| Sidebar entry label | Blog Posts | the plural label |
| Sidebar entry icon | none | — |
| Sidebar group | ungrouped | — |
Labels
protected static ?string $label = 'Article';
protected static ?string $pluralLabel = 'Articles';2
3
The label appears in the create page title (New Article), the create button, the view page subheading, and the success notifications (Article created.). The plural label is the list page heading, the breadcrumb, and the sidebar entry when no navigation label is declared.
Four static methods read them:
public static function defaultLabel(): string; // the class's own, before any panel configures it
public static function defaultPluralLabel(): string;
public static function label(): string; // as the current panel configured it
public static function pluralLabel(): string;2
3
4
The default* pair is what the class says; the pair without the prefix asks the current panel first. That matters when the same class is registered in two panels — see Per-panel configuration.
$pluralLabel is worth declaring whenever Str::plural() gets it wrong, which is most irregular nouns and every acronym.
The sidebar entry
use BackedEnum;
protected static ?string $navigationLabel = 'Articles';
protected static ?string $navigationIcon = 'newspaper';
protected static ?string $activeNavigationIcon = 'newspaper-solid';
protected static string|BackedEnum|null $navigationGroup = 'Content';
protected static int $navigationSort = 20;
protected static bool $shouldRegisterNavigation = true;2
3
4
5
6
7
8
9
10
11
12
13
| Property | Type | Default | Effect |
|---|---|---|---|
$navigationLabel | ?string | the plural label | The text of the entry |
$navigationIcon | ?string | null | An icon registry key |
$activeNavigationIcon | ?string | $navigationIcon | A second icon worn only while the entry is active |
$navigationGroup | string|BackedEnum|null | null | Which sidebar group it sits in |
$navigationSort | int | 0 | Order within the group; lower first |
$shouldRegisterNavigation | bool | true | Whether the entry exists at all |
Icons are registry keys, never component paths. The registry is a build-time allowlist: an unknown name resolves to nothing rather than to an error. After adding an icon name, rebuild the registry so the bundle contains it:
php artisan panel:icons # rewrite the registry from the source
php artisan panel:icons --check # fail if it is out of date, for CI2
Two accessors are public, because the navigation builder and global search both ask:
public static function navigationIcon(): ?string;
public static function activeNavigationIcon(): ?string; // falls back to navigationIcon()2
Groups
A group is named by a string or by a backed enum. An enum is worth reaching for the moment more than one class names the same group: a mistyped string is a second group that looks like the first and silently splits the sidebar in two, while a mistyped enum case does not compile.
use App\Enums\NavigationGroup;
protected static string|BackedEnum|null $navigationGroup = NavigationGroup::Content;2
3
Order is declared on the panel; groups the panel does not name are appended alphabetically:
$panel->navigationGroups([
'Content',
NavigationGroup::System,
'Access' => 'System', // nests Access under System
]);2
3
4
5
See Navigation groups.
Opting out of the sidebar
protected static bool $shouldRegisterNavigation = false;The resource keeps its routes and its URLs; only the entry disappears. That is the right declaration for a resource reached exclusively from another page.
Three other cases produce no entry, with no declaration involved:
- A nested resource. Its pages only exist beneath a parent record, and the sidebar has no parent in hand — there is no "all posts" to link to.
- A resource with no
indexpage. Building an entry for it would produce a link to a route that was never registered, which fails while rendering the sidebar and so takes down every page in the panel rather than just that one. - A resource the user may not view. The builder filters by
canViewAny()before anything else, so an unauthorized entry never even reaches badge evaluation.
Hiding is a convenience, never the security control: routes and actions authorize independently. See Authorization.
navigationItem()
use PandaPanel\Contracts\PanelContract;
use PandaPanel\Support\NavigationItem;
public static function navigationItem(PanelContract $panel): ?NavigationItem2
3
4
The method the navigation builder calls. It returns null in the three cases above, and otherwise a NavigationItem whose every field falls back to the class's own — so a panel states only what it wants to differ.
A resource's stock entry carries no badge. There is no $navigationBadge property; override the method to add one:
use App\Models\Article;
use PandaPanel\Contracts\PanelContract;
use PandaPanel\Support\NavigationItem;
public static function navigationItem(PanelContract $panel): ?NavigationItem
{
$item = parent::navigationItem($panel);
if ($item === null) {
return null;
}
return NavigationItem::make(
label: $item->label,
href: $item->href,
icon: $item->icon,
badge: static fn (): int => Article::query()->whereNull('published_at')->count(),
sort: $item->sort,
group: $item->group,
activeIcon: $item->activeIcon,
);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
A closure badge is evaluated on the server, for authorized items only, and only the scalar result crosses to Vue.
Clusters
use App\Panels\Admin\Clusters\ContentCluster;
protected static ?string $cluster = ContentCluster::class;2
3
Membership is declared by the member rather than listed on the cluster, so a class carries its own place in the panel and nothing has to be kept in two lists that can disagree. A clustered resource is listed under its cluster rather than beside it: the cluster is one sidebar entry that expands to its members and points at the first member the user may actually see.
The path gains the cluster's prefix; the route name does not. panel.admin.resources.articles.index still names the same route, so every Resource::url() already written keeps working. See Clusters.
Naming a single record
Breadcrumbs, page headings, sub-navigation, and search results ask the resource what one record is called:
protected static ?string $recordTitleAttribute = 'title';public static function recordTitle(Model $record): stringThe attribute defaults to name, and a non-scalar value falls back to the primary key. See Model binding.
Record sub-navigation
use PandaPanel\Enums\SubNavigationPosition;
protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Start;2
3
Top reads as tabs; Start and End are a rail beside the content. null — the default — takes the panel's own position, so a resource states one only when it differs from the rest.
public static function subNavigationPosition(): ?SubNavigationPositionThe links themselves are built from the resource's pages() map: the view and edit pages when they are declared and authorized for this record, plus any ManageRelatedRecords page. One link is not navigation, so a record with only one reachable page gets no bar at all. See Sub-navigation.
Per-panel overrides
Every navigation field can be restated for one panel without touching the class:
use PandaPanel\Resources\ResourceConfiguration;
$panel->resources([
ResourceConfiguration::for(ArticleResource::class)
->label('Story')
->pluralLabel('Stories')
->navigationLabel('Newsroom')
->navigationIcon('newspaper')
->navigationGroup('Editorial')
->navigationSort(5)
->registerNavigation(false),
]);2
3
4
5
6
7
8
9
10
11
12
Notes
- The navigation label falls back to the plural label, not to the singular one. Declaring
$labelalone leaves the sidebar reading the plural. $navigationSortorders within a group, not across the sidebar. Group order is the panel's declaration.- An unregistered icon name renders nothing and reports nothing. The registry is an allowlist by design; run
panel:iconsafter adding one. label()andpluralLabel()consult the current panel. Called from a console command, where there is no current panel, they answer the class's own defaults.- Navigation is rebuilt per request. Authorization results, badges, and active state depend on the user and the URL, so none of it is cached alongside the panel manifest.