사이드바가 정체성 위기를 겪을 때 — Filament 내비게이션 & 멀티패널
Filament에는 navigation.php가 없음. 사이드바는 리소스에서 자동 추론됨. 내비게이션 프로퍼티, 그룹, 배지, 멀티패널 아키텍처까지 한빛공방 예제로 정리한 삽질 기록.
TL;DR
Filament에는navigation.php같은 설정 파일이 없음. 사이드바는 리소스 클래스의 프로퍼티에서 자동 추론됨.$navigationIcon,$navigationGroup,$navigationSort로 제어하고, 멀티패널은PanelProvider를 분리해서 각각 독립된 경로·인증·리소스를 가지게 하면 됨. 근데 이걸 모르고config/navigation.php파일 찾아 헤맨 시간이 30분임.
"navigation.php 어디 갔음?"
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에서 그룹 순서 잡기
그룹 이름은 리소스에서 $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, 포탈은/portaldiscoverResources경로 분리: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: 값이 틀리면 아무것도 안 나옴.
디버깅 체크리스트:
- 리소스 파일의
namespace가for:매개변수의 하위 네임스페이스인지 확인 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 대시보드 공장 — 한빛공방 시리즈"**의 일부임.
전체 시리즈가 궁금하면 시리즈 목록에서 확인할 수 있음.