Branding, Logo, Icon, Favicon
What a panel is called, which icon it wears, and what colour it is. All of it is panel configuration serialized into the panel shared prop, so two panels in one build look different without either shipping a component. Nothing here is compiled: colours are CSS custom properties and icons are registry keys.
A branded panel
use PandaPanel\Core\Panel;
use PandaPanel\Core\PanelProvider;
final class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->path('admin')
->name('Administrator')
->brandName('Acme')
->icon('shield')
->colors(
light: ['primary' => '#4f46e5', 'sidebar' => 'oklch(0.98 0 0)'],
dark: ['primary' => '#818cf8'],
)
->cssHooks([
'topbar' => 'border-b-2 border-indigo-500',
]);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
The sidebar header then shows the icon, Acme on the first line and Administrator underneath, and every page of the panel is drawn with --primary set to the indigo.
Names
| Method | Signature | Default | Where it shows |
|---|---|---|---|
name | name(string $name): self | Str::headline($id) | Under the brand name in the sidebar; the panel switcher's first line. |
brandName | brandName(string $brandName): self | config('app.name') | The sidebar and header brand, the auth layout, the switcher's second line. |
Panel::make('back-office')->getName(); // 'Back Office'
Panel::make('admin')->getBrandName(); // whatever config('app.name') is2
Both are read on the frontend from panel.name and panel.brandName.
Icons
public function icon(?string $icon, ?string $darkIcon = null): self
public function darkIcon(?string $icon): self
public function getIcon(): ?string
public function getDarkIcon(): ?string2
3
4
An icon is a registry key, never a component, a path or an SVG:
$panel->icon('shield');
$panel->icon('sun', darkIcon: 'moon');
// or
$panel->icon('sun')->darkIcon('moon');2
3
4
Names resolve through resources/js/panel/icons/registry.ts, an import-based allowlist generated by php artisan panel:icons. A key that is not in the registry renders no icon at all — silently, because a decorative element must not be able to break the page it decorates.
The workflow is: write any Lucide name, then regenerate.
php artisan panel:icons # rewrite the registry from the names the PHP declares
php artisan panel:icons --check # fail instead of writing, for CI2
The command scans for ->icon('x'), the second argument of ->icon('light', 'dark'), ->darkIcon('x'), $navigationIcon = 'x', icon: 'x', 'icon' => 'x', Icon::make('x') and the body of any method named icon(), checks each name against the icons Lucide ships, and fails by name for one that does not exist. That failure is the only warning you get.
The panel icon is also what the panel switcher shows for each entry. In dark mode the switcher, sidebar/header brand, and auth layout use darkIcon when it exists and fall back to icon when it does not.
Logo and favicon
public function brandLogo(?string $brandLogo, ?string $darkBrandLogo = null): self
public function darkBrandLogo(?string $brandLogo): self
public function getBrandLogo(): ?string
public function getDarkBrandLogo(): ?string
public function favicon(?string $favicon, ?string $darkFavicon = null): self
public function darkFavicon(?string $favicon): self
public function getFavicon(): ?string
public function getDarkFavicon(): ?string2
3
4
5
6
7
8
9
All are plain strings, all default to null, and they are serialized to the frontend as panel.brandLogo, panel.darkBrandLogo, panel.favicon and panel.darkFavicon.
The shipped sidebar, header shell and panel auth layout render brandLogo when it exists, choosing darkBrandLogo in dark mode and falling back to the light logo otherwise. If no logo exists, they draw the Lucide panel icon. The panel switcher remains icon-based so entries stay compact.
<script setup lang="ts">
import { usePanel } from '@/panel/composables/usePanel';
const { panel } = usePanel();
</script>
<template>
<img v-if="panel?.brandLogo" :src="panel.brandLogo" :alt="panel.brandName" class="h-8" />
</template>2
3
4
5
6
7
8
9
favicon is still for the application's root view to render, because that file owns the document head:
{{-- resources/views/app.blade.php --}}
@if ($favicon = panel()?->getFavicon())
<link rel="icon" href="{{ $favicon }}">
@endif2
3
4
Dark mode
public function darkMode(bool $darkMode = true): self // true by default
public function hasDarkMode(): bool2
The value crosses as panel.darkMode. The header's light/dark toggle is rendered by PanelHeader.vue and driven by the host application's useAppearance composable. Branding variants are selected from the resolved appearance, so system follows prefers-color-scheme.
Colours
/**
* @param array<string, string> $light
* @param array<string, string> $dark
*/
public function colors(array $light, array $dark = []): self
/** @return array{light: array<string, string>, dark: array<string, string>} */
public function getTheme(): array2
3
4
5
6
7
8
Values, not meanings. A colour never becomes a Tailwind class, so the set is open — but both the property name and the value are validated by PandaPanel\Support\PanelTheme, and anything that fails is dropped rather than refused. A panel with one bad colour should render with the rest of its theme.
The properties a panel may set, all of PanelTheme::PROPERTIES:
primary | primary-foreground | secondary |
secondary-foreground | accent | accent-foreground |
background | foreground | muted |
muted-foreground | destructive | border |
ring | sidebar | sidebar-foreground |
sidebar-primary | sidebar-accent | sidebar-border |
Write them without the leading --; a leading dash is stripped. A name outside the list is dropped, because a typo would otherwise be a custom property nothing reads — a theme that silently does not apply.
Accepted value syntaxes, which is everything PanelTheme will let through:
$panel->colors([
'primary' => '#fff', // 3 to 8 hex digits
'secondary' => 'rgb(10, 20, 30)', // rgb() / rgba()
'accent' => 'hsl(210 40% 96%)', // hsl() / hsla()
'muted' => 'oklch(0.97 0 0)', // oklch(), which is what the stylesheet is written in
]);2
3
4
5
6
Anything else is dropped, because these end up inside a style attribute and red; content: url(...) is a stylesheet rather than a colour.
Panel::make('x')->colors([
'primary' => '#4f46e5',
'background' => 'red; content: url(https://evil.test)', // dropped
'primry' => '#ffffff', // dropped: unknown property
])->getTheme();
// ['light' => ['primary' => '#4f46e5'], 'dark' => []]2
3
4
5
6
Two calls merge rather than replace, so a plugin can contribute a colour without displacing the panel's.
How they are applied
usePanelStyling() turns the light palette into inline custom properties on the shell root, which is where the Tailwind v4 theme reads them:
import { usePanelStyling } from '@/panel/composables/usePanelStyling';
const { themeStyle, hook } = usePanelStyling();
// themeStyle === { '--primary': '#4f46e5', '--sidebar': 'oklch(0.98 0 0)' }2
3
4
The dark palette is serialized but not applied inline. An inline style cannot express "only under .dark", so the dark values travel in panel.theme.dark for a component or stylesheet to use. A theme that must differ by colour scheme belongs in a stylesheet loaded with assets():
/* resources/css/panels/admin.css */
.dark .panel-shell {
--primary: #818cf8;
}2
3
4
CSS hooks
/** @param array<string, string> $classes keyed by hook name */
public function cssHooks(array $classes): self
/** @return array<string, string> */
public function getCssHooks(): array2
3
4
5
Unlike colours, hook names are a closed allowlist — a name is a place in the layout, and a name nothing renders is a class a panel can set and never see.
$panel->cssHooks([
'topbar' => 'border-b-2 border-amber-500',
'table-row' => 'hover:bg-amber-50',
]);2
3
4
| Hook | Rendered by |
|---|---|
shell | SidebarPanelLayout.vue, HeaderPanelLayout.vue |
sidebar | PanelSidebar.vue |
topbar | PanelHeader.vue |
page | both layouts |
page-header | PageHeader.vue |
table, table-row | DataTable.vue |
form | FormRenderer.vue |
infolist | InfolistRenderer.vue |
widget | WidgetShell.vue |
modal | ActionModal.vue |
Every part already carries a stable panel-{name} class whether or not the panel says anything, which is what a stylesheet targets. cssHooks() adds to it:
hook('topbar'); // 'panel-topbar border-b-2 border-amber-500'
hook('sidebar'); // 'panel-sidebar' when the panel added nothing2
Two calls targeting one hook append rather than replace, because both meant it. An unknown name is dropped.
Notes
- Classes written in a panel provider are not in any file Tailwind scans. Either use classes that already appear in the application's own templates, or add the provider to Tailwind's content globs — otherwise the class exists in the DOM and not in the bundle.
colors()andcssHooks()are the cheap route. A panel that needs more ships a stylesheet throughassets(), or replaces the sidebar or topbar throughsidebarComponent()/topbarComponent().getTheme()andgetCssHooks()are always present intoSharedArray(), as['light' => [], 'dark' => []]and[]for a panel that declared nothing. The frontend reads them unconditionally.- Icons are the one place where an invalid value fails silently by design. Run
panel:icons --checkin CI and the silence stops being a problem.