Metadata Server ke Vue
Laravel bertanggung jawab atas registration, routing, authorization, query, validation, dan metadata. Vue bertanggung jawab atas rendering. Inertia adalah satu-satunya bridge di antara keduanya.
Semua data yang melewati bridge tersebut harus berupa scalar dan array. Closure dievaluasi di server; hanya hasil akhirnya yang dikirim ke browser.
Dokumentasi ini menjelaskan data apa saja yang dikirim dari backend ke Vue, bentuk payload yang digunakan, dan data apa saja yang sengaja tidak pernah dikirim.
Melihat Payload
Setiap Page PandaBear mengirim shared props ditambah props khusus Page tersebut.
Cara tercepat untuk melihat bentuk payload adalah melalui test:
use Inertia\Testing\AssertableInertia;
$this->actingAs($admin)
->get('/admin/users')
->assertInertia(fn (AssertableInertia $page) => $page
->component('panel/resources/Index')
->where('panel.id', 'admin')
->where('resource.slug', 'users')
->where('page.heading', 'Users')
->has('table.columns')
);2
3
4
5
6
7
8
9
10
11
Di browser, object yang sama tersedia pada:
usePage().propsShared Props
PandaPanel\Http\Middleware\SharePanelData membagikan tujuh key melalui Inertia::share().
Inertia::share() melakukan merge, sehingga shared data milik HandleInertiaRequests application Anda tetap tidak disentuh.
Setiap value dibungkus closure. Request yang tidak pernah masuk ke Panel tidak membayar cost untuk menghitung data Panel tersebut.
| Prop | Sumber | Value di Luar Panel |
|---|---|---|
panel | Panel::toSharedArray() | null |
navigation | NavigationBuilder::for() | [] |
panels | Panel yang dapat diakses user, untuk switcher | [] |
broadcasting | {enabled, channel} | {false, null} |
search | {enabled, url, debounce, keyBindings} | disabled |
notifications | {enabled, indexUrl, readUrl, clearUrl, unread} | disabled |
tenancy | {current, available} | null |
Di frontend, baca data tersebut melalui composable PandaBear, bukan mengakses usePage() secara langsung.
usePanel() menangani enam dari tujuh shared prop. navigation dibaca melalui useNavigation(), yang juga mengelola state collapsed group:
import { useNavigation } from '@/panel/composables/useNavigation';
import { usePanel } from '@/panel/composables/usePanel';
const { panel, panels, broadcasting, search, notifications, tenancy } = usePanel();
const { groups } = useNavigation();2
3
4
5
resources/js/panel/types/shared.ts adalah satu-satunya lokasi casting shared props di frontend PandaBear.
Type PanelSharedProps harus mirror middleware tersebut secara tepat. Contract test memastikan file lain di bawah resources/js/panel tidak membaca salah satu dari tujuh key itu langsung dari usePage().
panel
Panel::toSharedArray() mengembalikan shape berikut:
| Key | Type |
|---|---|
id, name, path | string |
brandName | string |
brandLogo, darkBrandLogo, icon, darkIcon, favicon, darkFavicon | string|null |
darkMode | bool |
maxContentWidth | string|null |
unsavedChangesAlerts | bool |
prefetch | 'hover'|'mount'|'click'|null |
errorNotifications | array<int, {title, body}|null> |
renderHooks | array<string, list<{component, data, scopes}>> |
sidebar | {collapsible, defaultOpen, variant, appearance, width, collapsedWidth, component} |
shell | {navigation, topbar, breadcrumbs, topbarComponent, userMenuItems} |
theme | {light: Record<string,string>, dark: Record<string,string>} |
cssHooks | Record<string, string> |
Data yang tidak ada sama pentingnya dengan data yang dikirim.
Hal-hal seperti:
- middleware;
- discovery paths;
- asset list;
databaseTransactions;strictAuthorization;- boot callbacks;
merupakan concern server dan tidak pernah menyeberang ke browser.
Frontend hanya menerima konfigurasi yang benar-benar perlu digunakannya.
Mirror TypeScript untuk shape ini adalah PanelDefinition di resources/js/panel/types/panel.ts.
navigation
NavigationBuilder::for(Panel $panel, string $currentPath): list<array> mengembalikan group navigation, yang masing-masing berisi item:
[
'label' => $this->label,
'href' => $this->href,
'icon' => $this->icon,
'activeIcon' => $this->activeIcon ?? $this->icon,
'badge' => $this->resolveBadge(),
'active' => $this->active,
'sort' => $this->sort,
'fullPage' => $this->fullPage,
'children' => [/* recursively */],
]2
3
4
5
6
7
8
9
10
11
Semua nilai navigation bersifat per request.
Authorization result, badge value, dan active state bergantung pada current user dan current URL, sehingga tidak boleh disimpan bersama Panel manifest.
activeIcon selalu dikirim, baik item sedang active maupun tidak. Dengan begitu sidebar dapat mengganti icon setelah client-side navigation tanpa menunggu backend memberi tahu ulang icon mana yang harus dipakai.
Metadata Page
Setiap Panel Page mengirim prop page dengan shape yang konsisten. Karena itu layout dapat merender header, breadcrumb, dan sub-navigation tanpa setiap Page harus menghubungkan komponen-komponen tersebut secara manual.
protected function metadata(): array
{
return [
'title' => static::title(),
'heading' => static::heading(),
'subheading' => static::$subheading,
'breadcrumbs' => array_map(fn (Breadcrumb $c): array => $c->toArray(), $this->breadcrumbs()),
'headerActions' => $this->headerActions(),
'scope' => static::renderHookScope(),
'cluster' => /* ClusterNavigation, or null */,
];
}2
3
4
5
6
7
8
9
10
11
12
| Key | Type | Catatan |
|---|---|---|
title | string | Judul browser tab |
heading | string | Mengikuti title kecuali Page memisahkannya |
subheading | string|null | |
breadcrumbs | list<{label, href, current}> | |
headerActions | list<array> | Action yang sudah diserialisasi |
scope | string|null | resource:{slug} atau page:{slug} |
cluster | {label, icon, position, items}|null | null jika Cluster tidak memiliki item visible |
subNavigation | {items, position} | Hanya Record Page |
Record Page — seperti View, Edit, atau custom Page yang menggunakan {record} — menambahkan subNavigation.
Standalone Page dan Resource Index tidak mengirim key tersebut sama sekali. Frontend normalizer mengubah ketidakhadiran key menjadi:
{
items: [],
position: 'top',
}2
3
4
bukan menganggapnya sebagai payload error. Page tanpa record memang tidak memiliki record-specific sub-navigation.
scope menggunakan slug, bukan PHP class name.
Tidak ada Page metadata yang boleh membawa nama class PHP. Karena alasan yang sama, Panel::renderHook() mengubah UserResource::class menjadi resource:users saat registration.
Pada sisi Vue, gunakan normalizer/composable:
import { usePanelPage } from '@/panel/composables/usePanelPage';
const page = usePanelPage(); // ComputedRef<PageMetadata | null>2
3
daripada membaca raw prop secara langsung.
Props Resource Page
ListRecords::render() menghasilkan salah satu payload paling besar di framework:
| Prop | Shape |
|---|---|
page | Page metadata |
resource | {slug, label, pluralLabel, indexUrl, parentKey} |
table | TableSchema::toArray() — columns, filters, groups, toolbar behavior |
state | {search, sort, direction, perPage, filters, filterIndicators, columnSearches, columns, group} |
tabs | list<array> |
headerWidgets, footerWidgets | Widget definitions |
widgetData | Deferred data untuk lazy Widget, atau null |
rows | Serialized cells, satu entry per record |
summaries, groupSummaries | Aggregate values |
pagination | Count dan link pagination |
actionEndpoints | {record, bulk, reorder, cell, table, form, infolist} |
actionEndpoints dikirim dari server sebagai data, bukan dirakit di Vue. Dengan begitu frontend tidak memiliki hardcoded Panel URL.
Endpoint tersebut tersedia pada seluruh Resource Page, bukan hanya Index Page, karena Infolist pada View Page juga dapat memiliki Action.
resource.parentKey dibutuhkan karena Action endpoint hanya tersedia satu set per Panel dan tidak memiliki parent segment pada URL-nya. Nested Resource perlu mengirim parent key pada setiap Action request.
Create dan Edit Page mengirim form sebagai pengganti table.
form dibangun dari:
FormSchema::toArray($record)setelah melalui fill hook milik Page.
Widget
Widget diserialisasi menjadi dua bagian.
public function toDefinition(): array // id, type, sort, columnSpan, lazy, heading, description, polling, filters, data$widgets->definitions(); // list<array>, one per visible widget
$widgets->deferred(); // Inertia::defer(fn () => ['widget-id' => $data]) — or null2
Widget non-lazy membawa data langsung di dalam definition.
Lazy Widget hanya mengirim definition pada initial response. Data sebenarnya datang melalui Inertia deferred prop.
deferred() mengembalikan null jika tidak ada lazy Widget pada Page, sehingga frontend tidak mengiklankan request kedua yang sebenarnya tidak akan pernah dibuat.
canView() diperiksa sebelum toArray() berjalan. Karena itu Widget unauthorized:
- tidak muncul dalam definitions;
- tidak menjalankan query;
- tidak menghitung data.
Aturan yang Berlaku di Semua Payload
- Metadata saja. Schema hanya boleh menjadi scalar dan array. Closure dievaluasi di server dan hanya result-nya yang dikirim.
- Tidak ada PHP class name. Render-hook scope, Page scope, dan Navigation Entry membawa slug.
ResourceRoutingTestmemastikan serialized Table definition tidak mengandungApp\ataupun SQL. - Tidak ada dynamic component resolution. Icon dan custom component dikirim sebagai nama yang di-resolve melalui build-time registry. Nama yang tidak dikompilasi tidak akan di-fetch dari server. Lihat Component Registries.
- Server state tidak dipindahkan menjadi arbitrary local state. Local state frontend dibatasi pada kebutuhan interaksi seperti debounced search input, working form values, row selection, dan collapsed navigation groups.
- URL adalah sumber Table state. Page, per-page, search, sort, direction, dan filter berada pada query string. Karena itu Back, Forward, Refresh, dan Bookmark tetap bekerja secara konsisten.
Validasi Payload, Jangan Sekadar Melakukan Type Assertion
Value dari PHP di-narrow menggunakan guard daripada langsung di-cast.
Jika shape tidak sesuai, frontend sebaiknya degrade menjadi empty/default state daripada melempar exception dari dalam layout.
export function normalizePageMetadata(value: unknown): PageMetadata | null {
if (!isRecord(value) || typeof value.heading !== 'string') {
return null;
}
return {
title: typeof value.title === 'string' ? value.title : value.heading,
heading: value.heading,
subheading: typeof value.subheading === 'string' ? value.subheading : null,
breadcrumbs,
headerActions: Array.isArray(value.headerActions) ? value.headerActions : [],
scope: typeof value.scope === 'string' ? value.scope : null,
subNavigation: toSubNavigation(value.subNavigation),
cluster: toCluster(value.cluster),
};
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Pattern yang sama digunakan pada:
cellGuards.tsuntuk Table Cell;widgetGuards.tsuntuk Widget payload.
Contohnya:
- sub-navigation position yang tidak dikenal fallback ke
'top'; - Cluster tanpa visible item menjadi
null.
Jika sebuah payload memang belum memiliki precise type, gunakan unknown[], bukan any.
headerActions adalah salah satu contohnya. Menggunakan unknown membuat compiler tetap memaksa consumer melakukan validasi saat data digunakan.
Mirror TypeScript
Setiap file di resources/js/panel/types/ mirror satu server-side shape:
| File | Mirror |
|---|---|
panel.ts | Panel::toSharedArray(), termasuk switcher, search, notification, dan tenancy props |
navigation.ts | NavigationGroup::toArray() dan NavigationItem::toArray() |
page.ts | Page::metadata() |
breadcrumb.ts | Breadcrumb::toArray() |
table.ts | TableSchema::toArray() |
form.ts | FormSchema::toArray() |
infolist.ts | InfolistSchema::toArray() |
action.ts | Action::toArray() |
widget.ts | Widget::toDefinition() |
relation.ts | Relation Manager payload |
shared.ts | SharePanelData |
Metadata union menggunakan field type sebagai discriminator.
Setiap switch terhadap union tersebut berakhir dengan exhaustive never check. Artinya jika backend menambahkan type baru tetapi frontend belum memiliki renderer, TypeScript akan menghasilkan compile error daripada membiarkan component kosong muncul diam-diam.
Catatan
- Shared props tersedia pada seluruh request
web, bukan hanya request Panel. Pada Page starter kit,paneltetap ada dengan valuenulldannavigationtetap ada sebagai[]. Frontend tidak perlu membedakan "prop hilang" dan "sedang berada di luar Panel". usePage()bersifat reactive dan props berubah selama client-side navigation.panelSharedProps()mengembalikanComputedRefkarena alasan tersebut. Mengambil snapshot satu kali dapat type-safe tetapi membuat sidebar terus menampilkan Panel lama setelah user berpindah.- Filtering scope Render Hook dilakukan di Vue, bukan server. Shared props dibuat oleh middleware sebelum request mencapai Page, sehingga middleware belum mengetahui Page mana yang akhirnya dirender.
- Notification unread count dibaca pada setiap Panel request, bukan melalui polling. Bell langsung benar setelah navigation tanpa round trip kedua. Query-nya adalah indexed count yang di-scope ke satu user.
- Tidak ada payload user/URL-dependent yang disimpan di Panel manifest.
bootstrap/cache/panels.phphanya menyimpan nama class. Lihat Caching.