사이드바가 정체성 위기를 겪을 때 — Filament 내비게이션 & 멀티패널

Filament에는 navigation.php가 없음. 사이드바는 리소스에서 자동 추론됨. 내비게이션 프로퍼티, 그룹, 배지, 멀티패널 아키텍처까지 한빛공방 예제로 정리한 삽질 기록.

TL;DR
Filament에는 navigation.php 같은 설정 파일이 없음. 사이드바는 리소스 클래스의 프로퍼티에서 자동 추론됨. $navigationIcon, $navigationGroup, $navigationSort로 제어하고, 멀티패널은 PanelProvider를 분리해서 각각 독립된 경로·인증·리소스를 가지게 하면 됨. 근데 이걸 모르고 config/navigation.php 파일 찾아 헤맨 시간이 30분임.


Laravel 프로젝트를 하다 보면 라우트 파일이 있고, 설정 파일이 있고, 뭐든 파일이 있음. 당연히 내비게이션도 어딘가에 정의 파일이 있을 거라고 생각했음.

find config/ -name "*nav*"
# 결과: 없음
# 아 혹시 sidebar?
find config/ -name "*sidebar*"
# 결과: 역시 없음

30분 동안 config/ 폴더를 뒤지고 나서야 깨달음. Filament은 리소스 클래스를 스캔해서 내비게이션을 자동 생성함. discoverResources()가 하는 일이 바로 그것임.

// AdminPanelProvider.php
return $panel
    ->discoverResources(
        in: app_path('Filament/Resources'),
        for: 'App\Filament\Resources'
    );

이 한 줄이 app/Filament/Resources 아래의 모든 리소스를 찾아서 사이드바에 자동으로 등록함. 설정 파일 같은 거 없음. 리소스가 곧 내비게이션임.


기본 내비게이션 프로퍼티

리소스 클래스에서 사이드바를 제어하는 기본 프로퍼티들:

class OrderResource extends Resource
{
    protected static ?string $model = Order::class;

    // 사이드바 아이콘 (Heroicon)
    protected static string|BackedEnum|null $navigationIcon 
        = Heroicon::OutlinedShoppingCart;

    // 사이드바에 표시될 이름 (기본: 모델명 복수형)
    protected static ?string $navigationLabel = '주문 관리';

    // 어느 그룹에 넣을지
    protected static ?string $navigationGroup = '판매';

    // 그룹 내 순서 (낮을수록 위)
    protected static ?int $navigationSort = 1;
}

실제 프로젝트 코드를 보면 이렇게 생겼음:

// 실제 OrderResource.php
class OrderResource extends Resource
{
    protected static ?string $model = Order::class;
    protected static string|BackedEnum|null $navigationIcon 
        = Heroicon::OutlinedRectangleStack;
    // ...
}

세 개 리소스가 전부 OutlinedRectangleStack 아이콘을 쓰고 있으면 사이드바가 복붙한 것처럼 보임. 정체성 위기의 시작임.

각 리소스에 맞는 아이콘을 써야 함:

리소스 추천 아이콘
Order Heroicon::OutlinedShoppingCart
Product Heroicon::OutlinedCube
Customer Heroicon::OutlinedUsers

그룹 이름은 리소스에서 $navigationGroup으로 지정하지만, 그룹 자체의 순서나 접힘 상태PanelProvider에서 설정함:

use Filament\Navigation\NavigationGroup;

return $panel
    ->navigationGroups([
        NavigationGroup::make('판매')
            ->icon(Heroicon::OutlinedShoppingBag)
            ->collapsed(false),  // 기본 펼침
        NavigationGroup::make('재고')
            ->icon(Heroicon::OutlinedArchiveBox)
            ->collapsed(true),   // 기본 접힘
        NavigationGroup::make('고객')
            ->icon(Heroicon::OutlinedUserGroup),
    ]);

여기서 정의한 순서대로 사이드바에 그룹이 나타남. 리소스에서 $navigationGroup = '판매'라고 쓰면 이 그룹에 들어감.

주의: 그룹 이름이 정확히 일치해야 함. '판매''판매 '(뒤에 공백)는 다른 그룹으로 인식됨. 이거 때문에 30분 날린 적 있음.


내비게이션 배지 — "대기 주문 47개" 빨간 배지

사이드바 메뉴 옆에 숫자 배지를 달 수 있음. 주문 관리에서 "처리 안 한 주문이 몇 개야"를 바로 보여주는 기능임:

class OrderResource extends Resource
{
    // ... 기존 코드

    public static function getNavigationBadge(): ?string
    {
        return static::getModel()::where('status', 'pending')
            ->count() ?: null;
        // null 반환하면 배지 안 보임
    }

    public static function getNavigationBadgeColor(): string|array|null
    {
        $count = static::getModel()::where('status', 'pending')->count();

        return match(true) {
            $count > 30 => 'danger',    // 빨강 — 일 좀 하셈
            $count > 10 => 'warning',   // 주황 — 슬슬 처리하셈
            $count > 0  => 'info',      // 파랑 — 여유 있음
            default     => null,
        };
    }
}

한빛공방에서 이걸 넣으니까 사장님이 "대기 주문 47개" 빨간 배지 보고 심장이 쫄깃해졌다고 함. 효과 만점임.

배지 텍스트는 string이라 숫자 말고 문자도 됨:

public static function getNavigationBadge(): ?string
{
    return '신규';  // 이렇게 텍스트도 가능
}

커스텀 내비게이션 아이템

리소스가 아닌 외부 링크나 커스텀 메뉴를 추가하고 싶을 때:

use Filament\Navigation\NavigationItem;

return $panel
    ->navigationItems([
        NavigationItem::make('공식 문서')
            ->url('https://filamentphp.com/docs', shouldOpenInNewTab: true)
            ->icon(Heroicon::OutlinedBookOpen)
            ->group('도움말')
            ->sort(100),

        NavigationItem::make('매출 리포트')
            ->url('/admin/reports/revenue')
            ->icon(Heroicon::OutlinedChartBar)
            ->group('분석')
            ->isActiveWhen(fn (): bool => 
                request()->routeIs('filament.admin.reports.*')
            )
            ->visible(fn (): bool => 
                auth()->user()?->hasRole('admin')
            ),
    ]);

isActiveWhen은 현재 URL이 해당 메뉴에 해당할 때 하이라이트 처리함. visible은 조건부 표시. 관리자만 보이는 메뉴 같은 거 만들 때 씀.


멀티패널 아키텍처

여기서부터 진짜 재밌어짐(그리고 삽질도 본격적으로 시작됨).

시나리오: 한빛공방

한빛공방은 도자기를 만들어서 도매로도 파는 곳임. 관리자용 어드민 패널이 있고, 도매 고객이 직접 주문을 넣는 포탈도 필요함.

  • 관리자 패널 (/admin): 모든 주문, 고객, 상품 관리
  • 도매 고객 포탈 (/portal): 자기 주문만 보고, 새 주문 넣기

패널 생성

php artisan make:filament-panel customer

이러면 app/Providers/Filament/CustomerPanelProvider.php가 생김:

class CustomerPanelProvider extends PanelProvider
{
    public function panel(Panel $panel): Panel
    {
        return $panel
            ->id('customer')
            ->path('portal')           // URL: /portal
            ->login()
            ->registration()           // 도매 고객 회원가입
            ->colors([
                'primary' => Color::Teal,  // 어드민과 다른 색
            ])
            ->discoverResources(
                in: app_path('Filament/Customer/Resources'),
                for: 'App\Filament\Customer\Resources'
            )
            ->discoverPages(
                in: app_path('Filament/Customer/Pages'),
                for: 'App\Filament\Customer\Pages'
            )
            ->discoverWidgets(
                in: app_path('Filament/Customer/Widgets'),
                for: 'App\Filament\Customer\Widgets'
            )
            ->authGuard('customer')     // 별도 인증 가드!
            ->middleware([
                EncryptCookies::class,
                AddQueuedCookiesToResponse::class,
                StartSession::class,
                AuthenticateSession::class,
                ShareErrorsFromSession::class,
                VerifyCsrfToken::class,
                SubstituteBindings::class,
                DisableBladeIconComponents::class,
                DispatchServingFilamentEvent::class,
            ])
            ->authMiddleware([
                Authenticate::class,
            ]);
    }
}

핵심 차이점:

  • path('portal'): 어드민은 /admin, 포탈은 /portal
  • discoverResources 경로 분리: Filament/Customer/Resources — 어드민 리소스와 완전히 다른 폴더
  • authGuard('customer'): 별도 가드. 이거 안 하면 큰일 남

Customer 모델에 FilamentUser 구현

use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;

class Customer extends Authenticatable implements FilamentUser
{
    public function canAccessPanel(Panel $panel): bool
    {
        // customer 패널만 접근 가능
        return $panel->getId() === 'customer';
    }
}

config/auth.php에 가드 추가도 잊지 말 것:

'guards' => [
    'web' => [
        'driver' => 'session',
        'provider' => 'users',
    ],
    'customer' => [
        'driver' => 'session',
        'provider' => 'customers',
    ],
],

'providers' => [
    'users' => [
        'driver' => 'eloquent',
        'model' => App\Models\User::class,
    ],
    'customers' => [
        'driver' => 'eloquent',
        'model' => App\Models\Customer::class,
    ],
],

리소스 공유 전략

"어드민에도 주문 리소스, 포탈에도 주문 리소스… 코드 복붙하라는 거임?"

추천 방식: 별도 리소스 + 공유 모델

app/Filament/Resources/Orders/OrderResource.php      ← 어드민용 (전체 주문)
app/Filament/Customer/Resources/MyOrderResource.php   ← 포탈용 (내 주문만)
app/Models/Order.php                                   ← 모델은 하나

포탈용 리소스는 scope를 걸어서 자기 주문만 보이게 함:

// 포탈용 MyOrderResource
class MyOrderResource extends Resource
{
    protected static ?string $model = Order::class;
    protected static ?string $navigationLabel = '내 주문';
    protected static string|BackedEnum|null $navigationIcon 
        = Heroicon::OutlinedClipboardDocumentList;

    public static function getEloquentQuery(): Builder
    {
        return parent::getEloquentQuery()
            ->where('customer_id', auth('customer')->id());
    }
}

모델은 공유하되 리소스를 분리하면 각 패널에 맞는 폼, 테이블, 권한을 독립적으로 관리할 수 있음. 리소스 하나를 두 패널에 등록하는 방법도 기술적으로 가능하긴 한데, 폼 필드나 테이블 컬럼이 달라지는 순간 조건문 지옥이 열림. 추천하지 않음.


흔한 함정들

1. 내비게이션에 리소스가 안 보임

원인 99%: 네임스페이스 불일치

// PanelProvider에서
->discoverResources(
    in: app_path('Filament/Resources'),
    for: 'App\Filament\Resources'  // ← 이 네임스페이스
)

리소스 파일의 실제 네임스페이스가 App\Filament\Resources\Orders처럼 하위 네임스페이스에 있어도 discoverResources가 재귀적으로 찾아줌. 하지만 for: 값이 틀리면 아무것도 안 나옴.

디버깅 체크리스트:

  • 리소스 파일의 namespacefor: 매개변수의 하위 네임스페이스인지 확인
  • in: 경로에 실제로 파일이 있는지 확인
  • php artisan filament:cache-components 실행해서 캐시 갱신

2. 패널 간 인증 혼동

문제: 어드민으로 로그인했는데 포탈에도 로그인되어 있음
원인: 두 패널이 같은 guard('web')를 쓰고 있음

각 패널에 별도 guard를 설정해야 함. 안 그러면 어드민 로그인 세션으로 고객 포탈에 접근하거나, 그 반대가 일어남. 보안 구멍임.

3. 리소스 누출

문제: 고객 포탈에서 관리자용 리소스가 보임
원인: discoverResources 경로가 너무 넓음
// 이렇게 하면 안 됨
->discoverResources(
    in: app_path('Filament'),           // 전체 Filament 폴더!
    for: 'App\Filament'
)

// 이렇게 해야 함
->discoverResources(
    in: app_path('Filament/Customer/Resources'),  // 해당 패널 전용 폴더
    for: 'App\Filament\Customer\Resources'
)

discoverResources는 지정된 경로를 재귀적으로 탐색함. 경로를 넓게 잡으면 의도하지 않은 리소스까지 등록됨. 고객에게 관리자 대시보드가 보이는 대참사가 벌어질 수 있음.


FAQ

Q: 내비게이션을 완전히 커스텀 Blade로 바꿀 수 있음?

가능함. resources/views/vendor/filament-panels/components/sidebar/를 퍼블리시해서 오버라이드하면 됨. 근데 Filament 업데이트할 때마다 깨질 수 있으니까 웬만하면 기본 API($navigationIcon, NavigationGroup, NavigationItem)로 해결하는 걸 추천함.

Q: 패널 3개 이상도 가능?

당연함. make:filament-panel을 원하는 만큼 실행하면 됨. 어드민, 고객 포탈, 배송 기사용 패널, 협력업체 패널… 각각 독립된 경로, 인증, 리소스를 가질 수 있음. 다만 패널이 늘어날수록 auth guard 관리가 복잡해지니까 정리 잘 해둘 것.

Q: 사이드바를 상단 내비게이션으로 바꿀 수 있음?

PanelProvider에서 ->topNavigation() 한 줄이면 됨. 사이드바가 통째로 상단 탭 스타일로 바뀜. 그룹은 드롭다운 메뉴가 됨.

return $panel
    ->topNavigation()  // 이 한 줄
    // ...

시리즈 내비게이션

이 글은 **"Filament 대시보드 공장 — 한빛공방 시리즈"**의 일부임.

전체 시리즈가 궁금하면 시리즈 목록에서 확인할 수 있음.