Contoh App Panel
Panel kedua yang disertakan di examples/: area untuk end-user pada /app yang dapat dimasuki oleh setiap user yang sudah authenticated dan verified. Panel ini tidak meregistrasikan satu pun resource milik Admin Panel. Inilah yang membuat isolasi antar-Panel dapat dibuktikan dari konfigurasi, bukan sekadar diasumsikan. Gunakan halaman ini ketika aplikasi membutuhkan Panel customer/member di samping Panel administratif.
Contoh minimal yang berfungsi
php artisan make:panel App// config/panda-panel.php
'panels' => [
App\Panels\Admin\AdminPanelProvider::class,
App\Panels\App\AppPanelProvider::class,
],2
3
4
5
6
Dua Panel berarti dua URL prefix dan dua registry terpisah. Tidak ada yang dibagikan di antara keduanya selain aplikasi Laravel yang sama di bawahnya.
Provider contoh lengkap
examples/app/Panels/App/AppPanelProvider.php:
<?php
declare(strict_types=1);
namespace App\Panels\App;
use PandaPanel\Core\Panel;
use PandaPanel\Core\PanelProvider;
final class AppPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->path('app')
->name('Application')
->brandName((string) config('app.name'))
->icon('layout-grid')
->auth()
->navigationGroups([
'Account',
])
->discoverResources(app_path('Panels/App/Resources'))
->discoverPages(app_path('Panels/App/Pages'))
->discoverWidgets(app_path('Panels/App/Widgets'));
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Tidak ada canAccess(). Itulah perbedaan utama dari sisi akses: auth() hanya mengharuskan user sudah sign in dan email terverifikasi, tanpa predicate tambahan. Artinya semua account yang dapat sign in dapat membuka /app. Admin Panel menambahkan predicate di atas middleware yang sama.
App\Models\User::canAccessPanel() tetap berlaku karena method tersebut ditanyakan untuk setiap Panel. Pada example, method itu mengharuskan email terverifikasi. Rule tentang account sebaiknya berada di sana; rule yang hanya berlaku untuk satu Panel sebaiknya berada di canAccess() milik Panel tersebut.
Apa yang ditemukan Panel
Panels/App/Resources kosong selain .gitkeep. Panel menemukan satu page dan satu widget.
Page
examples/app/Panels/App/Pages/Profile.php adalah standalone PandaPanel\Pages\Page — tidak memiliki model, table, maupun records, tetapi tetap mendapat layout Panel, navigation, breadcrumbs, dan authorization.
final class Profile extends Page
{
protected static ?string $title = 'Account overview';
protected static ?string $subheading = 'Your account details.';
protected static ?string $slug = 'profile';
protected static string $component = 'Panels/App/Pages/Profile';
protected static ?string $navigationIcon = 'user';
protected static string|BackedEnum|null $navigationGroup = 'Account';
protected static int $navigationSort = 5;
/** @return array<string, mixed> */
public function props(): array
{
$user = Auth::user();
return [
'profile' => $user instanceof User ? [
'name' => $user->name,
'email' => $user->email,
'verified' => $user->email_verified_at !== null,
'joined' => $user->created_at?->format('M j, Y'),
] : null,
];
}
/** @return list<array<string, mixed>> */
public function headerActions(): array
{
return [[
'name' => 'edit-profile',
'label' => 'Edit profile',
'icon' => 'settings',
'variant' => ActionVariant::Default->value,
'type' => 'link',
'url' => ProfileSettings::url($this->panel()),
'confirmation' => null,
]];
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
Ada dua pola penting yang layak diikuti.
props() mengembalikan scalar, bukan model. Data yang menyeberang ke Vue adalah representasi yang sengaja dipilih. Jika Eloquent model diserialisasi langsung, semua attribute atau relation yang kebetulan sedang loaded dapat ikut terkirim.
Header action mengarah ke built-in profile settings milik Panel, bukan membuat form edit kedua. PandaPanel\Pages\Settings\ProfileSettings::url($panel) menghasilkan /app/settings/profile, sehingga user tetap berada di shell yang sama selama melakukan perubahan profile.
Sisi Vue berada di examples/resources/js/pages/Panels/App/Pages/Profile.vue. Sebuah Page sebenarnya tidak wajib memiliki Vue file sendiri — jika $component dibiarkan pada default (panel/Page), generic page shell akan digunakan. Isi $component ketika page memang membutuhkan tampilan custom.
Widget
examples/app/Panels/App/Widgets/AccountSummary.php adalah StatsWidget yang scope-nya hanya user yang sedang sign in, bukan seluruh users table:
final class AccountSummary extends StatsWidget
{
protected static int $sort = 10;
protected static int|string|array $columnSpan = ['default' => 1, 'md' => 2, 'lg' => 3, 'xl' => 4];
public static function canView(): bool
{
return Auth::user() instanceof User;
}
/** @return list<Stat> */
public function stats(): array
{
$user = Auth::user();
if (! $user instanceof User) {
return [];
}
return [
Stat::make('Signed in as', $user->name)->icon('user'),
Stat::make('Email', $user->email_verified_at === null ? 'Unverified' : 'Verified')
->icon('mail')
->color($user->email_verified_at === null ? StatColor::Warning : StatColor::Success),
Stat::make('Member since', $user->created_at?->format('M Y') ?? '—')->icon('receipt'),
];
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
canView() diperiksa sebelum data() dijalankan. Artinya widget yang tidak boleh dilihat user tidak pernah menjalankan query miliknya. Pada example ini check tersebut menjadi lapisan tambahan di belakang middleware auth(), dan pola semacam ini baik untuk widget yang mungkin suatu saat digunakan kembali pada context yang lebih longgar.
Widget ini sama sekali tidak menghitung user lain. Itu konsisten dengan tanggung jawab App Panel yang dinyatakan langsung dalam code.
Navigation yang dihasilkan
Account
Account overview /app/profile
Profile /app/settings/profile
Security /app/settings/security
Appearance /app/settings/appearance2
3
4
5
tests/Feature/Panel/AppPanelExampleTest.php meng-assert daftar tersebut secara persis dan dengan urutan yang sama. Semua item berasal dari class hasil discovery atau built-in settings page milik Panel. Framework tidak memiliki hardcoded navigation array terpisah.
Built-in settings pages
Setiap Panel mendapatkan tiga account page di dalam shell dan path miliknya sendiri.
| Page | Path | Catatan |
|---|---|---|
PandaPanel\Pages\Settings\ProfileSettings | /app/settings/profile | nama dan email |
PandaPanel\Pages\Settings\SecuritySettings | /app/settings/security | password, two-factor, passkeys; berada di balik RequirePassword |
PandaPanel\Pages\Settings\AppearanceSettings | /app/settings/appearance | theme, sepenuhnya client-side |
Page tersebut hanya merender UI. Proses write tetap menuju ProfileController dan SecurityController milik aplikasi yang melakukan redirect back(). Dengan demikian hanya ada satu tempat untuk mengubah profile, tidak peduli dari Panel mana form dikirim.
Nonaktifkan untuk Panel yang memang tidak memerlukan settings:
$panel->settings(false);URL /settings/* milik starter kit tetap dipertahankan sebagai alias dan mengarahkan user ke Panel pertama yang dapat mereka akses. Link lama dan output Wayfinder tetap berfungsi.
Apa yang menjaga dua Panel tetap terisolasi
Setiap Panel memiliki registry sendiri. Meminta URL resource melalui Panel yang tidak meregistrasikan resource tersebut akan melempar exception:
use App\Panels\Admin\Resources\Users\UserResource;
UserResource::url(); // fine inside /admin
UserResource::url(panel: 'app'); // PanelRegistrationException2
3
4
tests/Feature/Panel/PanelIsolationTest.php menulis jaminan tersebut sebagai test. Ini bukan convention semata: Resource::assertRegisteredIn() dijalankan pada setiap pemanggilan url(). Karena itu isolasi dapat dibuktikan secara deterministik, bukan bergantung pada kebetulan konfigurasi.
Tiga konsekuensi lain dari rule yang sama:
- Route milik App Panel berada pada route group terpisah dengan middleware stack terpisah. Resource route yang tidak pernah diregistrasikan menghasilkan 404, bukan 403.
- Action endpoint seperti
panel.app.actions.recordme-resolve resource slug terhadap registry Panel saat ini. Payload yang menyebutusersmelalui/apptidak menemukan resource apa pun. - Global search hanya memeriksa resource yang diregistrasikan Panel dan selalu menjalankan
canViewAny()sebelum melakukan query.
Berbagi satu resource antara dua Panel
Jika satu class memang benar-benar relevan di dua Panel, daftarkan class yang sama pada keduanya lalu konfigurasi per Panel. Jangan membuat subclass hanya demi mengganti label:
use PandaPanel\Resources\ResourceConfiguration;
$panel->resources([
ResourceConfiguration::for(UserResource::class)
->slug('people')
->pluralLabel('People')
->navigationLabel('Directory')
->navigationGroup('Account')
->modifyQueryUsing(fn (Builder $query) => $query->whereKey(auth()->id())),
]);2
3
4
5
6
7
8
9
10
Semua method ResourceConfiguration bersifat fluent dan opsional:
| Method | Signature |
|---|---|
for | public static function for(string $resource): self |
slug | public function slug(string $slug): self |
label | public function label(string $label): self |
pluralLabel | public function pluralLabel(string $pluralLabel): self |
navigationLabel | public function navigationLabel(string $navigationLabel): self |
navigationGroup | public function navigationGroup(?string $navigationGroup): self |
navigationIcon | public function navigationIcon(?string $navigationIcon): self |
navigationSort | public function navigationSort(int $navigationSort): self |
registerNavigation | public function registerNavigation(bool $register = true): self |
modifyQueryUsing | public function modifyQueryUsing(Closure $callback): self |
modifyQueryUsing() mempersempit Resource::query(), dan seluruh proses read melewati query yang sama — list, view, edit, delete, bulk operation, action lookup, maupun global search. Record yang tidak dapat dijangkau Panel menjadi 404, bukan sekadar row yang tersembunyi dari table.
Fitur ini sengaja bukan cara meregistrasikan satu class dua kali di dalam Panel yang sama. Resource di-key berdasarkan slug; dua registrasi class yang sama akan membuat Resource::url() menjadi ambigu.
Hal yang perlu diperhatikan
- Panel tanpa
canAccess()terbuka untuk semua authenticated user. Ini sering kali benar untuk App Panel, tetapi hampir tidak pernah benar untuk Admin Panel. Tambahkan predicate secara sengaja sesuai kebutuhan. ->auth()bersifat accumulating. Method menambahkanauthdanverifiedke auth middleware stack, bukan menggantinya.->auth(verified: false)hanya menambahkanauth.- Directory resource yang kosong tidak masalah. Discovery atas directory kosong hanya menghasilkan nol registration. Setelah panel manifest tersedia, scan itu bahkan tidak terjadi per request.
- Dua Panel dapat dipasang pada domain berbeda. Gunakan
->domain('app.example.com')untuk membatasi host. Tanpa domain restriction, Panel tersedia pada seluruh host yang dilayani aplikasi. - Settings page bersifat per Panel, bukan global. Menonaktifkan settings pada satu Panel tidak memengaruhi Panel lain.
Lihat juga
- Contoh Admin Panel — Panel administratif yang berada di samping Panel ini
- Multiple Panels
- Per-Panel Resource Configuration
- Built-in Settings Pages
- Custom Pages
- Widgets Overview
- Panel Access
- Authorization