Panels
Sebuah Panel adalah satu area admin yang telah dikonfigurasi: memiliki URL prefix, middleware stack, access rule, serta kumpulan Resource, Page, dan Widget yang hidup di dalamnya.
Setiap Panel merupakan instance PandaPanel\Core\Panel, dibangun satu kali saat Provider boot dan tidak dimutasi oleh request.
Gunakan dokumentasi ini ketika Anda perlu memahami konfigurasi apa saja yang dapat diberikan ke Panel dan nilai apa yang dapat dibaca kembali dari object tersebut.
Panel Minimal
Ada dua perubahan utama.
Pertama, buat Provider yang mengonfigurasi Panel:
<?php
declare(strict_types=1);
namespace App\Panels\Admin;
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')
->auth()
->discoverResources(app_path('Panels/Admin/Resources'))
->discoverPages(app_path('Panels/Admin/Pages'))
->discoverWidgets(app_path('Panels/Admin/Widgets'));
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Kemudian daftarkan Provider tersebut pada config/panda-panel.php:
'panels' => [
App\Panels\Admin\AdminPanelProvider::class,
],2
3
Konfigurasi tersebut sudah menghasilkan Panel yang berfungsi pada /admin, dilindungi oleh web, auth, dan verified, memiliki Dashboard, tiga Account Settings Page bawaan, serta seluruh class yang ditemukan dari tiga discovery path.
Panel sengaja dicantumkan secara eksplisit pada config. File tersebut menjadi satu tempat tempat seluruh Panel application dapat dilihat sekaligus.
Menambahkan Panel harus menjadi perubahan yang disengaja, bukan side effect dari keberadaan file di filesystem.
Urutan pada array config tidak menentukan prioritas. Panel diproses berdasarkan id, bukan config order.
Class di dalam sebuah Panel dapat ditemukan otomatis melalui discovery. Lihat Discovery.
Membuat Panel di Luar Provider
Panel::make() adalah constructor yang digunakan oleh API registration lain:
use PandaPanel\Core\Panel;
use PandaPanel\Facades\PandaPanel;
$panel = Panel::make('reports')->path('reports');
PandaPanel::register($panel);2
3
4
5
6
| Method | Signature | Catatan |
|---|---|---|
make | static make(?string $id = null): self | Tanpa id, Anda harus memanggil id() sebelum id dibaca. |
register | PandaPanel::register(Panel $panel): Panel | Lihat Panel Providers. |
getId() melempar PandaPanel\Exceptions\PanelRegistrationException jika id tidak pernah ditetapkan.
Framework sengaja tidak mengembalikan empty string karena value tersebut dapat secara diam-diam menghasilkan route name seperti:
panel..dashboardKonvensi Penamaan API
Fluent setter menggunakan nama langsung:
->path()
->middleware()2
Reader menggunakan prefix get:
getPath()
getMiddleware()2
PHP tidak mendukung method overloading seperti beberapa bahasa lain. PandaBear menghindari API setter/getter gabungan yang harus mengembalikan union seperti string|static, karena behavior semacam itu membuat type dan intent method menjadi kurang jelas.
Dua behavior penting sebelum membaca tabel API berikut:
- Discovery path, navigation group, full-page pattern, dan assets bersifat akumulatif. Memanggil method dua kali menambahkan value, bukan mengganti value sebelumnya. Dengan begitu module dapat berkontribusi pada Panel tanpa mengubah konfigurasi inti.
middleware()bersifat replace. Method ini menentukan base stack dan default-nya adalah['web'].
Identity
$panel
->id('admin')
->name('Administrator')
->path('back-office')
->domain('admin.example.com');2
3
4
5
| Method | Signature | Default jika Tidak Diatur |
|---|---|---|
id | id(string $id): self | Di-seed dari nama class Provider. |
name | name(string $name): self | Str::headline($id). |
path | path(string $path): self | Panel id. Slash awal/akhir dibersihkan. |
domain | domain(?string $domain): self | null — cocok dengan semua host. |
| Reader | Return |
|---|---|
getId() | string, melempar exception jika belum pernah diatur |
getName() | string |
getPath() | string |
getDomain() | string|null |
getRouteNamePrefix() | string — "panel.{id}." |
routeName(string $name) | string — prefix ditambah $name |
$panel->routeName('resources.users.index'); // panel.admin.resources.users.indexAccess
use App\Models\User;
use Illuminate\Contracts\Auth\Authenticatable;
$panel
->middleware(['web'])
->authMiddleware(['auth', 'verified'])
->auth()
->canAccess(static fn (?Authenticatable $user): bool => $user instanceof User && $user->is_admin);2
3
4
5
6
7
8
| Method | Signature | Catatan |
|---|---|---|
middleware | middleware(array $middleware): self | Mengganti base stack. Default ['web']. |
authMiddleware | authMiddleware(array $middleware): self | Mengganti auth stack yang ditambahkan setelah base stack. Default ['auth']; gunakan [] hanya untuk public Panel. |
auth | auth(bool $verified = true): self | Me-merge auth, dan verified kecuali $verified false. Default auth stack sudah memiliki auth. |
canAccess | canAccess(Closure $callback): self | Closure(?Authenticatable): bool. |
| Reader | Return |
|---|---|
getMiddleware() | list<string> — base + auth stack, deduplicated |
getBaseMiddleware() | list<string> |
getAuthMiddleware() | list<string> |
isAccessibleTo(?Authenticatable $user) | bool |
isAccessibleTo() menanyakan dua aturan dan keduanya harus mengizinkan:
- predicate milik Panel;
PanelUser::canAccessPanel()pada User Model jika model mengimplementasikan contract.
User Model yang tidak mengimplementasikan PanelUser tidak ditolak oleh contract tersebut.
Authenticated user yang gagal pada access check mendapatkan 403, bukan redirect.
Lihat Authorization.
Registration
use App\Panels\Admin\Resources\Users\UserResource;
use App\Panels\Admin\Widgets\UserStats;
$panel
->resources([UserResource::class])
->pages([App\Panels\Admin\Pages\Settings::class])
->widgets([UserStats::class])
->discoverResources(app_path('Panels/Admin/Resources'))
->discoverPages(app_path('Panels/Admin/Pages'))
->discoverWidgets(app_path('Panels/Admin/Widgets'));2
3
4
5
6
7
8
9
10
| Method | Signature |
|---|---|
resources | resources(array $resources): self — class string atau instance ResourceConfiguration |
pages | pages(array $pages): self |
widgets | widgets(array $widgets): self |
discoverResources | discoverResources(string ...$paths): self |
discoverPages | discoverPages(string ...$paths): self |
discoverWidgets | discoverWidgets(string ...$paths): self |
| Reader | Return |
|---|---|
getResources() | list<class-string> — hanya explicit registration |
getResourceConfigurations() | list<ResourceConfiguration> |
getPages() | list<class-string> — Settings Pages bawaan berada di awal kecuali settings(false) |
getWidgets() | list<class-string> |
getResourceDiscoveryPaths() | list<string> |
getPageDiscoveryPaths() | list<string> |
getWidgetDiscoveryPaths() | list<string> |
Explicit registration dan discovery digabungkan. Class yang ditemukan melalui kedua cara hanya muncul satu kali.
Daftar final yang benar-benar dimiliki Panel berada pada registry, bukan hanya pada list konfigurasi di object Panel.
Lihat Panel Providers — Registries.
Landing Page
use PandaPanel\Pages\Dashboard;
use App\Panels\Admin\Pages\AccountsDashboard;
$panel->dashboards([
Dashboard::class,
AccountsDashboard::class,
]);2
3
4
5
6
7
| Method | Signature | Catatan |
|---|---|---|
dashboard | dashboard(string $page): self | class-string<Page> yang dirender pada root Panel. |
dashboards | dashboards(array $pages): self | Entry pertama menjadi root; sisanya mendapat route sendiri. |
| Reader | Return |
|---|---|
getDashboard() | class-string<Page>, default PandaPanel\Pages\Dashboard |
getExtraDashboards() | list<class-string<Page>> |
Extra Dashboard tetap merupakan Page class biasa.
Artinya setiap Dashboard tambahan memiliki:
- authorization sendiri;
- navigation entry sendiri;
- filter sendiri;
- route sendiri.
Presentation
$panel
->brandName('Acme Admin')
->brandLogo('/images/logo-light.svg', '/images/logo-dark.svg')
->icon('sun', darkIcon: 'moon')
->favicon('/favicon-light.ico', '/favicon-dark.ico')
->darkMode()
->maxContentWidth('7xl');2
3
4
5
6
7
| Method | Signature | Default |
|---|---|---|
brandName | brandName(string $brandName): self | config('app.name') |
brandLogo | brandLogo(?string $brandLogo, ?string $darkBrandLogo = null): self | null |
darkBrandLogo | darkBrandLogo(?string $brandLogo): self | null |
icon | icon(?string $icon, ?string $darkIcon = null): self | null — icon registry key, bukan path |
darkIcon | darkIcon(?string $icon): self | null |
favicon | favicon(?string $favicon, ?string $darkFavicon = null): self | null |
darkFavicon | darkFavicon(?string $favicon): self | null |
darkMode | darkMode(bool $darkMode = true): self | true |
maxContentWidth | maxContentWidth(?string $maxContentWidth): self | null |
maxContentWidth adalah token yang dipetakan frontend ke literal Tailwind class.
Token yang didukung:
full
7xl
6xl
5xl
4xl
3xl2
3
4
5
6
Value lain fallback ke max-w-full.
Mapping literal diperlukan karena Tailwind class yang dibangun melalui runtime interpolation tidak dijamin masuk ke bundle.
Colors dan CSS Hooks
$panel
->colors(
light: ['primary' => '#4f46e5', 'sidebar' => 'oklch(0.98 0 0)'],
dark: ['primary' => '#818cf8'],
)
->cssHooks([
'topbar' => 'border-b-2 border-amber-500',
'table-row' => 'hover:bg-amber-50',
]);2
3
4
5
6
7
8
9
| Method | Signature |
|---|---|
colors | colors(array $light, array $dark = []): self |
cssHooks | cssHooks(array $classes): self |
getTheme() | array{light: array<string, string>, dark: array<string, string>} |
getCssHooks() | array<string, string> |
Keduanya melakukan validation secara silent.
Color property yang tidak digunakan stylesheet dibuang.
Color value yang tidak dapat diparse sebagai salah satu bentuk berikut juga dibuang:
#rgb
rgb()
hsl()
oklch()2
3
4
Value warna dimasukkan ke attribute style, sehingga string seperti:
red; content: url(…)bukan sekadar warna dan tidak boleh diterima.
cssHooks hanya menerima sebelas hook yang didefinisikan pada PandaPanel\Support\CssHooks::HOOKS:
shell
sidebar
topbar
page
page-header
table
table-row
form
infolist
widget
modal2
3
4
5
6
7
8
9
10
11
Dua pemanggilan yang menarget hook sama akan append class karena keduanya dianggap disengaja.
Shell
$panel
->sidebar(collapsible: true, defaultOpen: true, variant: 'sidebar', appearance: 'inset')
->topNavigation(false)
->sidebarWidth('18rem', '4rem')
->navigation()
->topbar()
->breadcrumbs()
->sidebarComponent('Panels/Admin/Shell/Sidebar')
->topbarComponent('Panels/Admin/Shell/Topbar')
->userMenuItems([
['label' => 'Status page', 'url' => 'https://status.example.com', 'icon' => 'link'],
]);2
3
4
5
6
7
8
9
10
11
12
| Method | Signature | Default |
|---|---|---|
sidebar | sidebar(bool $collapsible = true, bool $defaultOpen = true, string $variant = 'sidebar', string $appearance = 'inset'): self | seperti contoh |
topNavigation | topNavigation(bool $topNavigation = true): self | mengubah variant menjadi header atau sidebar |
sidebarWidth | sidebarWidth(string $width, ?string $collapsedWidth = null): self | '16rem' |
collapsedSidebarWidth | collapsedSidebarWidth(string $width): self | '3rem' |
navigation | navigation(bool $navigation = true): self | true |
topbar | topbar(bool $topbar = true): self | true |
breadcrumbs | breadcrumbs(bool $breadcrumbs = true): self | true |
sidebarComponent | sidebarComponent(?string $component): self | null |
topbarComponent | topbarComponent(?string $component): self | null |
userMenuItems | userMenuItems(array $items): self | [] |
$variant memiliki dua value:
sidebar
header2
$appearance memiliki:
sidebar
floating
inset2
3
Header shell mengabaikan appearance.
Width menggunakan CSS length karena value akhirnya menjadi CSS custom property.
Mengubah width menjadi dynamic Tailwind class akan menimbulkan masalah build-time karena class hasil interpolation mungkin tidak pernah masuk ke bundle.
sidebarComponent dan topbarComponent menerima build-time registry key di bawah:
resources/js/pages/Panels/{Panel}/Shell/Bukan markup dan bukan arbitrary file path.
Lihat Component Registries.
| Reader | Return |
|---|---|
getSidebar() | array{collapsible, defaultOpen, variant, appearance, width, collapsedWidth, component} |
getShell() | array{navigation, topbar, breadcrumbs, topbarComponent, userMenuItems} |
hasNavigation(), hasTopbar(), hasBreadcrumbs() | bool |
getUserMenuItems() | list<array<string, mixed>> |
getMaxContentWidth() | string|null |
Behavior
$panel
->settings()
->databaseTransactions()
->strictAuthorization()
->unsavedChangesAlerts()
->bootUsing(fn (Panel $panel) => /* every request into this panel */ null);2
3
4
5
6
| Method | Signature | Default | Reader |
|---|---|---|---|
settings | settings(bool $settings = true): self | true | hasSettings() |
databaseTransactions | databaseTransactions(bool $databaseTransactions = true): self | true | hasDatabaseTransactions() |
strictAuthorization | strictAuthorization(bool $strictAuthorization = true): self | false | hasStrictAuthorization() |
unsavedChangesAlerts | unsavedChangesAlerts(bool $unsavedChangesAlerts = true): self | true | hasUnsavedChangesAlerts() |
bootUsing | bootUsing(Closure $callback): self | tidak ada | getBootCallbacks() |
configureActions | configureActions(Closure $callback): self | tidak ada | actionConfigurator() |
settings(true) menambahkan tiga Page berikut di bagian awal getPages():
ProfileSettings
SecuritySettings
AppearanceSettings2
3
Karena semuanya diperlakukan sebagai Page biasa, discovery, caching, dan route registration memprosesnya dengan aturan yang sama seperti Page lainnya.
Boot callback bersifat akumulatif dan dijalankan oleh ResolvePanel setelah access check.
User yang ditolak masuk ke Panel tidak akan memicu boot callback.
boot() menjalankan plugin boot() terlebih dahulu, kemudian callback milik Panel. Dengan urutan ini application memiliki kesempatan terakhir untuk menyesuaikan behavior setelah plugin selesai boot.
configureActions() menerima:
Closure(Action): voiddan diterapkan ke setiap Action saat Action tersebut dibangun.
Schema yang kemudian mengatur property Action secara eksplisit tetap dapat menjadi keputusan akhir.
Contoh:
use PandaPanel\Actions\Action;
use PandaPanel\Actions\Enums\ActionVariant;
$panel->configureActions(static function (Action $action): void {
if ($action->getVariant() === ActionVariant::Destructive) {
$action->requiresConfirmation();
}
});2
3
4
5
6
7
8
Behavior Navigation
$panel
->navigationGroups([
'Content',
'Access' => 'System', // nests Access under System
])
->prefetch('hover')
->fullPageUrls('/admin/exports/*')
->errorNotification(403, 'Not allowed', 'Ask an administrator.')
->hideErrorNotification(404)
->subNavigationPosition(SubNavigationPosition::Start);2
3
4
5
6
7
8
9
10
| Method | Signature | Default |
|---|---|---|
navigationGroups | navigationGroups(array $groups): self | [] — string atau backed enum; string key menjadi child dan value menjadi parent |
prefetch | prefetch(bool|string $prefetch = 'hover'): self | 'hover'; false → null, true → 'hover' |
fullPageUrls | fullPageUrls(string ...$patterns): self | [] — pattern Str::is() |
errorNotification | errorNotification(int $status, string $title, ?string $body = null): self | default tersedia untuk 403, 404, 419, 429, 500, 503 |
hideErrorNotification | hideErrorNotification(int $status): self | — |
subNavigationPosition | subNavigationPosition(SubNavigationPosition $position): self | SubNavigationPosition::Top |
| Reader | Return |
|---|---|
getNavigationGroups() | list<string> |
getNavigationGroupParents() | array<string, string> dengan child label sebagai key |
getPrefetch() | 'hover'|'mount'|'click'|null |
getFullPageUrls() | list<string> |
isFullPageUrl(string $url) | bool — mencocokkan URL dan path-nya jika absolute |
getErrorNotifications() | array<int, array{title: string, body: string|null}|null> |
getSubNavigationPosition() | SubNavigationPosition |
Error notification milik Panel di-merge dengan default framework.
Karena itu Panel dapat mengcustom satu HTTP status tanpa harus menuliskan ulang konfigurasi lain.
Jika entry diatur menjadi null, framework menonaktifkan:
- toast error;
- Inertia error overlay;
untuk status tersebut.
HTTP status yang sama sekali tidak memiliki entry diserahkan ke behavior Inertia.
Search, Notifications, dan Broadcasting
$panel
->globalSearch(enabled: true, limit: 50, debounce: 300, keyBindings: ['mod+k'])
->notifications()
->broadcasting();2
3
4
| Method | Signature | Default |
|---|---|---|
globalSearch | globalSearch(bool $enabled = true, int $limit = 50, int $debounce = 300, array $keyBindings = ['mod+k']): self | seperti contoh |
notifications | notifications(bool $notifications = true): self | true |
broadcasting | broadcasting(bool $broadcasting = true): self | true |
| Reader | Return |
|---|---|
hasGlobalSearch() | bool |
getGlobalSearchLimit() | int — limit seluruh search, bukan per Resource |
getGlobalSearchDebounce() | int dalam millisecond |
getGlobalSearchKeyBindings() | list<string> |
hasNotifications() | bool |
hasBroadcasting() | bool |
getBroadcastChannel(?Authenticatable $user) | string|null |
getBroadcastChannel() mengembalikan null jika:
- broadcasting dimatikan; atau
- tidak ada user yang login.
Frontend kemudian tidak mencoba subscribe ke channel apa pun, daripada memiliki channel yang pasti akan ditolak.
Authentication Milik Panel
$panel
->login()
->registration()
->passwordReset()
->emailVerification()
->requireTwoFactor();2
3
4
5
6
| Method | Signature | Default | Reader |
|---|---|---|---|
login | login(bool $login = true): self | false | hasLogin() |
registration | registration(bool $registration = true): self | false | hasRegistration() |
passwordReset | passwordReset(bool $passwordReset = true): self | false | hasPasswordReset() |
emailVerification | emailVerification(bool $emailVerification = true): self | false | hasEmailVerification() |
requireTwoFactor | requireTwoFactor(bool $required = true): self | false | requiresTwoFactor() |
login() mendaftarkan guest Page milik Panel di luar auth middleware dan mengubah tujuan redirect guest ketika membuka protected Panel URL.
registration(), passwordReset(), dan emailVerification() hanya menambahkan Page dan hanya berlaku ketika login() aktif.
Lihat Routing — Guest Routes.
Tenancy
use App\Models\Team;
use Illuminate\Http\Request;
$panel
->tenant(Team::class, fn (Request $request) => Team::query()
->where('slug', $request->route('team'))
->first())
->tenantUrlUsing(fn (Team $team, Panel $panel): string => "/{$panel->getPath()}/{$team->slug}");2
3
4
5
6
7
8
| Method | Signature |
|---|---|
tenant | tenant(string $model, Closure $resolver): self — Closure(Request, ?Authenticatable): ?Model |
tenantUrlUsing | tenantUrlUsing(Closure $url): self — Closure(Model, Panel): string |
| Reader | Return |
|---|---|
hasTenancy() | bool |
getTenantModel() | class-string<Model>|null |
resolveTenant(Request $request, ?Authenticatable $user) | Model|null |
getTenantUrl(Model $tenant) | string|null |
Jika resolver mengembalikan object yang bukan instance model tenant yang dideklarasikan, framework memperlakukannya sebagai tenant tidak ditemukan dan menghasilkan 404.
Validation type ini penting.
Resolver yang salah dan misalnya mengembalikan User Model dapat membuat semua query terlihat seolah ter-scope berdasarkan user id, padahal type object-nya salah.
Tanpa tenantUrlUsing(), tenant switcher tidak dirender karena framework tidak tahu URL yang harus dibangun untuk tenant pilihan.
Plugins
$panel->plugins([
new AcmeBillingPlugin,
]);2
3
| Method | Signature |
|---|---|
plugins | plugins(array $plugins): self — instance PanelPlugin |
getPlugins() | array<string, PanelPlugin> dengan plugin id sebagai key |
hasPlugin(string $id) | bool |
plugin(string $id) | PanelPlugin|null |
register() milik plugin dijalankan segera ketika Panel sedang dibangun.
boot() milik plugin berjalan saat Panel::boot() setelah Panel di-resolve untuk request.
Duplicate plugin id melempar:
PanelRegistrationException::duplicatePlugin()Render Hooks dan Assets
use PandaPanel\Enums\RenderHook;
use App\Panels\Admin\Resources\Users\UserResource;
$panel
->renderHook(
RenderHook::HeaderEnd,
'Panels/Admin/Hooks/Announcement',
['message' => 'Maintenance at 5pm'],
[UserResource::class],
)
->assets('resources/css/panels/admin.css');2
3
4
5
6
7
8
9
10
11
| Method | Signature |
|---|---|
renderHook | renderHook(RenderHook $hook, string $component, array $data = [], array $scopes = []): self |
assets | assets(string ...$entrypoints): self |
getRenderHooks() | array<string, list<array{component: string, data: array, scopes: list<string>}>> |
getAssets() | list<string> |
Scope Render Hook direduksi menjadi slug saat registration:
Resource subclass → resource:{slug}
Page subclass → page:{slug}2
Value lain dianggap sudah berupa scope key.
Nama PHP class tidak pernah diserialisasi ke frontend.
Scope list kosong berarti Hook berlaku untuk seluruh Page pada Panel.
RenderHook memiliki delapan case:
BodyStart
BodyEnd
SidebarStart
SidebarEnd
HeaderStart
HeaderEnd
PageStart
PageEnd2
3
4
5
6
7
8
Assets merupakan Vite entrypoint yang ditambahkan ke asset application hanya pada Page milik Panel tersebut.
Daftar asset tidak pernah dikirim sebagai Inertia prop.
Lihat Frontend Assets.
Data yang Dikirim ke Vue
$panel->toSharedArray();Method tersebut mengembalikan:
id
name
path
brandName
brandLogo
darkBrandLogo
icon
darkIcon
favicon
darkFavicon
darkMode
maxContentWidth
unsavedChangesAlerts
prefetch
errorNotifications
renderHooks
sidebar
shell
theme
cssHooks2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Concern server tidak ikut dikirim, termasuk:
- transaction configuration;
- strict authorization;
- boot callbacks;
- middleware;
- discovery paths;
- asset list.
SharePanelData mengirim hasil tersebut pada setiap Panel Page melalui prop panel.
Lihat Metadata Server ke Vue.
Catatan
- Panel kedua dengan id yang sama melempar
PanelRegistrationException::duplicatePanelId(). - Panel kedua dengan kombinasi path dan domain yang sama melempar
duplicatePanelPath(). Keduanya dianggap developer error karena jika dibiarkan, satu route group dapat menimpa yang lain secara diam-diam. PandaPanel::all()mengembalikan Panel yang diurutkan berdasarkan id. Karena itu route registration order stabil danfirstAccessibleTo()menggunakan urutan yang sama.- Path matching menggunakan prefix terpanjang terlebih dahulu. Panel
/admin/reportsmenang atas/adminuntuk request/admin/reports/x. getPages()mencakup built-in Settings Page.getResources()dangetWidgets()hanya mencakup explicit registration. Class hasil discovery berada pada registry, bukan list konfigurasi Panel.- Panel dikonfigurasi sekali saat boot dan dapat digunakan lintas request pada long-running worker. Logic per-user harus ditempatkan di boot callback atau Page, bukan pada method
panel()Provider.