Filament v4 Custom Theme & Branding: White-Label Your Admin Panel

Build a Filament custom theme and white-label your v4 panel: brand colours, logo, favicon, a Tailwind v4 theme, login branding, and per-tenant overrides.

Steven Richardson
Steven Richardson
· 16 min read

Out of the box, every Filament panel looks exactly the same — the same indigo-on-white chrome, the same text logo built from your app name. That's fine for an internal tool, but the moment you put a panel in front of a client, or sell it as white-label SaaS, "looks like every other Filament app" stops being acceptable. This guide walks through every layer of Filament branding, from a one-line primary colour change to a full Filament custom theme and per-tenant white-labelling where each customer logs into what looks like their own product.

I've shipped panels where five agency clients each log into the same Laravel codebase and never see a hint of shared infrastructure — different logos, different palettes, different login screens. The pieces to do that are all in Filament v4, but they're scattered across the colours, styling, render-hooks, and tenancy docs. Here we'll assemble them into one path, in the order you actually need them.

We build this up in layers on purpose, because that mirrors how much control you genuinely need. Most "branding" tickets are solved by the first two steps and never touch a CSS file. The path from default to fully branded looks like this:

  • Set the panel's primary colour (and the full semantic palette) with one config call.
  • Swap the brand logo, a separate dark mode logo, and the favicon.
  • Generate a custom Tailwind v4 theme when you need real CSS control over the chrome.
  • Customise fonts and the sidebar/topbar to match a design system.
  • Brand the login and authentication pages so the front door matches.
  • Go multi-tenant white-label: per-tenant logos and colours resolved at request time.
  • Build, cache, and test the result so it ships safely to production.

If you haven't stood up a panel yet, start with the zero-to-production Filament v4 guide and come back here to brand it. Everything below assumes you have a working panel provider such as app/Providers/Filament/AdminPanelProvider.php.

Set the panel's primary color#

The single highest-impact change is the primary colour, and it needs zero CSS. Filament ships six semantic colours — primary, success, warning, danger, info, and gray — and maps each to an 11-shade palette at runtime. You pass a colour and Filament picks accessible shades for backgrounds, text, borders, and hover states for you. Set them in the colors() method of your panel provider.

use Filament\Panel;
use Filament\Support\Colors\Color;

public function panel(Panel $panel): Panel
{
    return $panel
        ->id('admin')
        ->path('admin')
        ->colors([
            'primary' => Color::Indigo,
            'danger' => Color::Rose,
            'gray' => Color::Slate,
            'info' => Color::Blue,
            'success' => Color::Emerald,
            'warning' => Color::Amber,
        ]);
}

Color::Indigo and friends are Tailwind palettes bundled with Filament. But brand colours rarely line up with Tailwind's defaults, so pass your own hex and let Filament generate the 11 shades for you:

->colors([
    'primary' => '#6366f1', // your brand hex — Filament derives the palette
])

If you have a designer-supplied palette and want pixel-exact control, pass the shades directly in OKLCH. Filament uses CSS variables under the hood, and OKLCH keeps tints and shades perceptually even — the same reasoning behind OKLCH design tokens in Tailwind v4:

->colors([
    'primary' => [
        50 => 'oklch(0.962 0.018 272.314)',
        100 => 'oklch(0.93 0.034 272.788)',
        // ... 200–800 ...
        900 => 'oklch(0.379 0.146 265.522)',
        950 => 'oklch(0.257 0.09 281.288)',
    ],
])

Set primary colors globally across panels

Colours set in a panel provider apply to that panel only. If you run multiple panels, or want the same brand everywhere including outside Filament, register them globally from a service provider's boot() method with the FilamentColor facade:

use Filament\Support\Colors\Color;
use Filament\Support\Facades\FilamentColor;

public function boot(): void
{
    FilamentColor::register([
        'primary' => Color::hex('#6366f1') ?? Color::Indigo,
        'gray' => Color::Slate,
    ]);
}

A genuinely common mistake starts here: reaching for make:filament-theme just to change the primary colour. Don't. If your only goal is brand colours, the brand logo, and accent buttons, colors() covers it with no extra build step and no CSS file to maintain. Generate a theme only when you need structural changes that colour tokens can't reach.

Swap the logo, dark-mode logo, and favicon#

By default Filament renders a text logo from your app name. There are three branding hooks here — the brand name, the brand logo (with a dark-mode variant), and the favicon — and all three live in the same panel provider. Start with the simplest: change the text, or replace it with an image.

use Filament\Panel;

public function panel(Panel $panel): Panel
{
    return $panel
        ->brandName('Acme Admin')                       // text logo
        ->brandLogo(asset('images/logo.svg'))           // image logo
        ->brandLogoHeight('2rem')                        // tune for your aspect ratio
        ->favicon(asset('images/favicon.png'));
}

brandLogoHeight() matters more than it looks — Filament can't guess your logo's aspect ratio, so a wide wordmark will overflow the sidebar header until you set a height. For full control, pass a Blade view to brandLogo() and render an inline SVG, which lets you colour the mark with Tailwind classes that respond to light and dark mode:

->brandLogo(fn () => view('filament.brand.logo'))
->brandLogoHeight('2.25rem')
{{-- resources/views/filament/brand/logo.blade.php --}}
<svg viewBox="0 0 128 26" xmlns="http://www.w3.org/2000/svg"
     class="h-full fill-gray-700 dark:fill-white">
    <!-- your paths -->
</svg>

For a raster logo that doesn't recolour cleanly, supply a dedicated dark mode logo instead. Filament swaps it automatically when the panel is in dark mode:

->brandLogo(asset('images/logo-light.svg'))
->darkModeBrandLogo(asset('images/logo-dark.svg'))

That handles the Filament dark mode logo without any JavaScript. The brand logo also appears on the login screen automatically, so these few lines already get you most of the way to a branded front door — we'll finish that in a later step.

Generate a custom Tailwind v4 theme#

When colour tokens and a logo aren't enough — you want a different sidebar treatment, custom spacing, a brand font, or your own Tailwind classes inside Filament Blade views — you need a custom theme. Filament v4 is built on Tailwind CSS v4, so a theme is a CSS file compiled by Vite rather than a JavaScript config. Generate one with the dedicated Artisan command, passing your panel ID:

php artisan make:filament-theme admin

Prefer Bun? Pass --pm=bun. In Filament v4 this command does the wiring that used to be manual: it installs the Tailwind dependencies, generates resources/css/filament/admin/theme.css, adds that file to the input array in vite.config.js, and registers it on your panel with ->viteTheme(). If your config has non-standard formatting it prints manual instructions instead — in that case add the file yourself:

use Filament\Panel;

public function panel(Panel $panel): Panel
{
    return $panel
        ->viteTheme('resources/css/filament/admin/theme.css');
}

What make:filament-theme generates

The generated theme.css is intentionally minimal — an import of Filament's base theme plus @source directives that tell Tailwind v4 where to scan for class names:

@import '../../../../vendor/filament/filament/resources/css/theme.css';

@source '../../../../app/Filament/**/*';
@source '../../../../resources/views/filament/**/*';

This is where the most painful Filament theming bug lives. Tailwind v4 only generates the classes it actually sees in scanned files, and the generated @source paths cover Filament's own directories — not your custom Blade components, Livewire views, or anything under resources/views/components. Use a Tailwind class there without registering the path and Tailwind silently purges it, so styles work locally but vanish in your production build. Add every directory where you write classes:

@import '../../../../vendor/filament/filament/resources/css/theme.css';

@source '../../../../app/Filament/**/*';
@source '../../../../resources/views/filament/**/*';
@source '../../../../resources/views/components/**/*';
@source '../../../../resources/views/livewire/**/*';
@source '../../../../app/Livewire/**/*';

This is the same class-detection model as the rest of Tailwind v4 — if dynamic class names are involved, you'll want the @source inline() safelisting technique too. If you're new to the CSS-first config, the Tailwind v3-to-v4 upgrade guide explains why tailwind.config.js is gone and @source replaced the old content array. Rebuild with npm run build after editing @source, and remember: a custom theme is also the only way to use arbitrary Tailwind utilities (text-primary-600, p-4) in your own Filament views — the default compiled stylesheet doesn't include them.

Customize fonts and panel chrome#

With a theme compiling, you can reshape the actual chrome. Two things usually matter for brand: the font and the sidebar/topbar styling. The font is the easy win and doesn't even need the theme — font() in the panel provider pulls any Google Font, served via the GDPR-friendly Bunny Fonts CDN by default:

use Filament\Panel;

public function panel(Panel $panel): Panel
{
    return $panel->font('Poppins');
}

For a licensed or self-hosted brand typeface — the usual case for Filament custom fonts in a white-label product — declare it in your theme.css with @font-face and wire it into Tailwind v4's @theme so every component inherits it:

@theme {
    --font-sans: 'Satoshi', ui-sans-serif, system-ui, sans-serif;
}

@font-face {
    font-family: 'Satoshi';
    src: url('/fonts/Satoshi-Variable.woff2') format('woff2');
    font-weight: 300 900;
    font-display: swap;
}

Now the panel chrome. Filament exposes "hook classes" prefixed with fi-fi-sidebar, fi-topbar, fi-btn, and so on — and these are the supported surface for CSS overrides. Target them with @apply in your theme:

.fi-sidebar {
    @apply bg-gray-900;
}

.fi-sidebar .fi-sidebar-item-active .fi-sidebar-item-button {
    @apply bg-primary-600 text-white;
}

.fi-topbar {
    @apply border-b border-gray-800;
}

The fastest way to find the right class is browser dev tools: inspect the element you want to change and look for the class starting with fi-. Two warnings save you future pain. First, only style fi- hook classes — classes without that prefix are internal implementation details that can change between releases and break your overrides without notice. Second, resist overriding too much: every structural rule you write against Filament's markup is a rule that can break on upgrade. Lean on colour tokens, CSS variables, and a handful of hook-class tweaks. A theme should adapt Filament to your brand, not rebuild it. (And never edit anything in vendor/filamentcomposer update will wipe it.) If you also support light and dark palettes, the Tailwind v4 CSS-first dark mode approach pairs cleanly with Filament's :root[class~="dark"] selector.

Brand the login and authentication pages#

The login page is the first thing a client sees, and a default Filament login under a custom logo still feels generic. Your brandLogo() already renders here, but to add a tagline, support links, or a marketing panel you'll use render hooks — Filament's supported way to inject Blade at specific points without publishing views. The panel exposes auth-specific hooks; AUTH_LOGIN_FORM_BEFORE and AUTH_LOGIN_FORM_AFTER wrap the login form. Register them right on the panel:

use Filament\Panel;
use Filament\View\PanelsRenderHook;
use Illuminate\Contracts\View\View;

public function panel(Panel $panel): Panel
{
    return $panel
        ->renderHook(
            PanelsRenderHook::AUTH_LOGIN_FORM_BEFORE,
            fn (): View => view('filament.auth.login-branding'),
        );
}
{{-- resources/views/filament/auth/login-branding.blade.php --}}
<div class="mb-6 text-center">
    <p class="text-sm text-gray-500 dark:text-gray-400">
        Welcome back to {{ filament()->getBrandName() }}. Sign in to manage your account.
    </p>
</div>

The same constants exist for registration and password-reset flows (AUTH_REGISTER_FORM_BEFORE, AUTH_PASSWORD_RESET_REQUEST_FORM_AFTER, and so on), so you can brand every authentication screen consistently. If you'd rather register hooks globally — for example from a service provider that brands all panels at once — use the FilamentView facade instead:

use Filament\Support\Facades\FilamentView;
use Filament\View\PanelsRenderHook;

FilamentView::registerRenderHook(
    PanelsRenderHook::AUTH_LOGIN_FORM_AFTER,
    fn (): string => view('filament.auth.support-links')->render(),
);

When you need to change the login layout itself — not just inject content — extend Filament's base Login page and point it at your own Blade view. In Filament v4 the auth pages live under the Filament\Auth namespace:

namespace App\Filament\Auth;

use Filament\Auth\Pages\Login as BaseLogin;

class Login extends BaseLogin
{
    protected static string $view = 'filament.auth.login';
}

Register it on the panel with ->login(\App\Filament\Auth\Login::class). For most white-label work, render hooks plus a custom logo are enough — reach for a custom page only when you're rebuilding the screen. This is also where you'd customise headings or the form schema by overriding methods like getHeading() or form().

Apply per-tenant white-label branding#

This is the payoff: in a multi-tenant SaaS, one codebase serving many tenants, each tenant should see its own brand. The pattern is to store branding on the tenant model and apply it at request time through Filament's tenancy middleware, so the logo and colours change based on whoever's panel is loaded. It builds directly on a tenancy setup — if you haven't got one, the Filament v4 multi-tenancy with teams guide and the broader Laravel 13 multi-tenancy guide cover the foundation this step assumes.

First, store branding on the tenant. A JSON config column keeps it flexible, and accessor methods give you sensible fallbacks:

use Illuminate\Support\Arr;

class Team extends Model
{
    protected $fillable = ['name', 'logo', 'config'];

    protected function casts(): array
    {
        return ['config' => 'array'];
    }

    public function getBrandLogo(): string
    {
        return $this->logo
            ? asset('storage/' . $this->logo)
            : asset('images/logo.svg'); // default when a tenant hasn't branded yet
    }

    public function getPrimaryColor(): string
    {
        return Arr::get($this->config, 'colors.primary', '#6366f1');
    }
}

Let tenant admins manage these from a profile page using Filament's own form components — a FileUpload for the logo and a ColorPicker for the primary colour:

use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\FileUpload;

FileUpload::make('logo')
    ->image()
    ->directory('logos')
    ->visibility('public'),

ColorPicker::make('config.colors.primary')
    ->hexColor(),

Now apply it. Create a middleware that reads the active tenant and overrides the current panel's logo and colours. Because Filament resolves the panel before tenancy middleware runs, you can mutate the current panel instance and register tenant colours on the fly:

php artisan make:middleware ApplyTenantBranding
namespace App\Http\Middleware;

use Closure;
use Filament\Facades\Filament;
use Filament\Support\Facades\FilamentColor;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class ApplyTenantBranding
{
    public function handle(Request $request, Closure $next): Response
    {
        $tenant = Filament::getTenant();

        if ($tenant) {
            Filament::getCurrentPanel()
                ->brandLogo($tenant->getBrandLogo())
                ->brandLogoHeight('2.5rem');

            FilamentColor::register([
                'primary' => $tenant->getPrimaryColor(),
            ]);
        }

        return $next($request);
    }
}

Register the middleware as tenant middleware so it runs once a tenant is resolved:

use App\Http\Middleware\ApplyTenantBranding;
use App\Models\Team;

public function panel(Panel $panel): Panel
{
    return $panel
        ->tenant(Team::class)
        ->brandLogo(asset('images/logo.svg')) // fallback for login / no tenant
        ->tenantMiddleware([
            ApplyTenantBranding::class,
        ], isPersistent: true);
}

Mark it isPersistent: true so the branding survives Livewire's subsequent AJAX requests, not just the initial page load — otherwise the logo reverts to the default the moment a table paginates. The login screen has no tenant yet, which is exactly why the panel-level brandLogo() fallback matters: it's what unauthenticated visitors and tenant-less routes see.

Build and deploy the themed assets#

A custom theme is compiled CSS, so it has to be built and cached like any other front-end asset. Locally, run the dev server or a one-off build:

npm run dev      # watch while developing
npm run build    # production assets

If you change colours via colors() or FilamentColor::register() only, there's nothing to rebuild — those are injected as CSS variables at runtime. It's the theme.css work (fonts, hook-class overrides, @source changes) that requires npm run build. In your deploy pipeline, build assets and warm Filament's caches so component discovery and Blade icons don't pay a first-request penalty:

npm ci && npm run build
php artisan filament:cache-components
php artisan icons:cache
php artisan optimize

Run filament:cache-components after deploying code, and clear it on rollback. If you serve assets from a CDN, make sure the hashed theme.css from the Vite manifest is published — a stale manifest is the usual reason a freshly deployed theme doesn't show up. Should you hit Unable to locate file in Vite manifest, it almost always means the build didn't run or the theme path isn't in vite.config.js's input array.

Test your branded panel#

Branding is easy to break silently — a logo path typo, a colour that drops on the second Livewire request, a purged class. A few cheap tests catch the regressions that matter. Start with feature tests asserting the brand actually renders:

use App\Models\User;
use function Pest\Laravel\actingAs;

it('shows the brand name on the login page', function () {
    $this->get('/admin/login')
        ->assertOk()
        ->assertSee('Acme Admin');
});

it('boots the panel for an authenticated user', function () {
    actingAs(User::factory()->create())
        ->get('/admin')
        ->assertOk();
});

For the white-label path, assert that the active tenant's logo is the one served — the regression that's almost impossible to eyeball across dozens of tenants:

use App\Models\Team;
use App\Models\User;
use function Pest\Laravel\actingAs;

it('serves the active tenant logo', function () {
    $team = Team::factory()->create([
        'logo' => 'logos/acme.svg',
        'config' => ['colors' => ['primary' => '#7c3aed']],
    ]);
    $user = User::factory()->create();
    $team->members()->attach($user);

    actingAs($user)
        ->get(filament()->getUrl($team))
        ->assertOk()
        ->assertSee('logos/acme.svg', false);
});

Run the suite with composer test (or php artisan test --compact). For the visual layer that assertions can't reach — does the sidebar actually look right in dark mode, does the logo fit — a quick browser test is worth it; Pest 4 browser testing with Playwright can screenshot the login and dashboard so design regressions show up in CI rather than in a client's inbox.

Wrapping up#

You now have a panel that's branded at every layer a client sees: a primary palette and semantic colours, a brand logo with a dark-mode variant and favicon, a compiled Tailwind v4 theme for fonts and chrome, a branded login, and — the real prize — per-tenant white-labelling that resolves each customer's logo and colours at request time. The mental model worth keeping: use colors() and brandLogo() for everything you can, generate a theme only when you need CSS that tokens can't express, and push anything tenant-specific through middleware so it's data, not deployment.

From here, three natural next steps. Make the brand carry into your data displays by building a stats overview widget with trend sparklines in your palette. Extend the white-label experience into bespoke inputs with a custom Filament v4 form field. And if you're branding multiple panels, revisit the complete zero-to-production Filament v4 guide to keep their configuration consistent. Brand once, test it, and every tenant gets a panel that feels built just for them.

FAQ#

How do I create a custom theme in Filament?

Run php artisan make:filament-theme admin, passing your panel's ID. In Filament v4 the command installs the Tailwind v4 dependencies, generates resources/css/filament/admin/theme.css, adds it to your vite.config.js input array, and registers it on the panel with ->viteTheme(). You then edit the generated CSS and run npm run build. Only create a theme when you need CSS-level control — for colours and a logo alone, the panel's colors() and brandLogo() methods are enough.

How do I change the primary color in Filament?

Call ->colors(['primary' => '#6366f1']) in your panel provider and Filament generates the full 11-shade palette from your hex, picking accessible shades for each component automatically. You can also pass a bundled palette like Color::Indigo, or an exact OKLCH array if a designer supplied one. To apply colours across every panel at once, register them globally with FilamentColor::register() from a service provider's boot() method. None of these require a CSS rebuild — colours are injected as CSS variables at runtime.

How do I change the logo in a Filament panel?

Use ->brandLogo(asset('images/logo.svg')) in your panel provider, and set ->brandLogoHeight('2rem') to control sizing for your logo's aspect ratio. To recolour a mark for light and dark mode, pass a Blade view that renders an inline SVG instead of an image URL. If you just want to change the text logo, ->brandName('Acme Admin') is all you need. The logo automatically appears in the sidebar, topbar, and on the login page.

How do I use a different logo for dark mode in Filament?

Set a separate dark-mode image with ->darkModeBrandLogo(asset('images/logo-dark.svg')) alongside your normal ->brandLogo(). Filament swaps to it automatically when the panel is in dark mode, with no JavaScript required. As an alternative for vector logos, render the logo as an inline SVG via a Blade view and use Tailwind's dark: variant classes (for example fill-gray-700 dark:fill-white) so a single asset adapts to both modes.

Can I have different branding per tenant in Filament?

Yes — this is how white-label SaaS is built on Filament. Store each tenant's logo and colours on the tenant model (a JSON config column works well), then register a tenant middleware that reads the active tenant and overrides the current panel's brandLogo() and registers its primary colour with FilamentColor. Mark the middleware isPersistent: true so the branding survives Livewire's follow-up requests, and keep a panel-level fallback logo for the login screen where no tenant is resolved yet.

How do I customise the Filament login page?

The supported approach is render hooks: register content at PanelsRenderHook::AUTH_LOGIN_FORM_BEFORE or AUTH_LOGIN_FORM_AFTER from your panel with ->renderHook(), returning a Blade view with your tagline, links, or marketing content. The brand logo already renders on the login page automatically. When you need to change the layout rather than inject content, extend Filament v4's base Login page (under the Filament\Auth\Pages namespace), point its $view at your own Blade file, and register it with ->login().

Does Filament v4 use Tailwind CSS v4 for themes?

Yes. Filament v4 is built on Tailwind CSS v4, which uses CSS-first configuration — there's no tailwind.config.js. Generated themes import Filament's base theme and use @source directives to tell Tailwind where to scan for classes, and you customise design tokens with the @theme directive. The most common gotcha is that the generated @source paths only cover Filament's own directories, so you must add paths for your own Blade and Livewire views or Tailwind will purge those classes from the production build.

Why aren't my Tailwind classes working in Filament Blade views?

Because Filament's default compiled stylesheet only contains the classes its own UI needs — arbitrary utilities like text-primary-600 or p-4 aren't included. To use Tailwind classes in your own views you must generate a custom theme with make:filament-theme, then add @source directives pointing at the directories where you write those classes (your components, Livewire views, and so on) and run npm run build. If classes work in development but disappear in production, a missing @source path is almost always the cause.

Do I need to rebuild assets after changing Filament colors?

It depends on which mechanism you used. Changing colours through ->colors() or FilamentColor::register() needs no rebuild — Filament passes the palette to the browser as CSS variables at runtime, so the change is live immediately. Editing your theme.css — fonts, hook-class overrides, or @source paths — does require npm run build (or npm run dev while developing), because that CSS is compiled by Vite. In production, also run php artisan filament:cache-components after deploying so component discovery is cached.

Steven Richardson
Steven Richardson

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