Pendekatan Inertia dan Vue
Separuh frontend dari batas itu: apa yang diletakkan PHP di kabel, komponen Vue mana yang membacanya, dan aturan apa yang mencegah ketidakcocokan bentuk menjatuhkan sebuah halaman. Baca halaman ini sebelum menulis kolom, field, widget, halaman, atau komponen shell kustom — kelimanya di-resolve lewat registry yang sama saat build.
Satu halaman, dari ujung ke ujung
Server mendeklarasikan halaman. Tidak ada yang perlu menyambungkan layout di app.ts:
namespace App\Panels\Admin\Pages;
use PandaPanel\Pages\Page;
final class Reports extends Page
{
protected static ?string $title = 'Reports';
protected static string $component = 'Panels/Admin/Pages/Reports';
}2
3
4
5
6
7
8
9
10
<!-- resources/js/pages/Panels/Admin/Pages/Reports.vue -->
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import PageHeader from '@/panel/components/PageHeader.vue';
import PanelLayout from '@/panel/layouts/PanelLayout.vue';
import type { PageMetadata } from '@/panel/types/page';
defineOptions({ layout: PanelLayout });
defineProps<{ page: PageMetadata }>();
</script>
<template>
<Head :title="page.title" />
<PageHeader :heading="page.heading" :subheading="page.subheading" />
</template>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
npm run buildHalaman yang tidak punya sesuatu yang khas untuk digambar sama sekali tidak butuh berkas Vue: Page::$component default-nya panel/Page, yaitu renderer generiknya.
Inertia adalah satu-satunya jembatan
Tidak ada API SPA terpisah. Guard auth yang sama, middleware yang sama, routing yang sama, sesi yang sama, flash toast yang sama, dan proses build yang sama melayani layar panel maupun layar aplikasi. Batas API kedua berarti menduplikasi otorisasi di batas itu tanpa keuntungan apa pun.
Yang menyeberang adalah sebuah deskripsi. TableSchema, FormSchema, InfolistSchema, Action, dan Widget semuanya diserialisasi menjadi skalar dan array. Closure hanya hidup di server: warna badge, predikat visible(), dan tooltip() dievaluasi saat serialisasi dan hanya hasilnya yang berjalan. Itulah sebabnya definisi panel boleh memuat logika aplikasi tanpa satu pun bagiannya menjadi bagian dari bundel klien.
Props bersama
PandaPanel\Http\Middleware\SharePanelData membagikan tujuh props di setiap request web lewat Inertia::share(), yang sifatnya menggabungkan — HandleInertiaRequests milik Anda tidak disentuh. Cerminan TypeScript-nya adalah PanelSharedProps di resources/js/panel/types/shared.ts:
export interface PanelSharedProps {
panel: PanelDefinition | null; // null di luar panel, tidak pernah absen
navigation: NavigationGroup[];
panels: PanelSummary[];
broadcasting: PanelBroadcasting;
search: PanelSearchSettings;
notifications: PanelNotificationSettings;
tenancy: PanelTenancy | null; // null untuk panel tanpa tenancy
}2
3
4
5
6
7
8
9
Setiap nilai di sisi PHP berupa closure, jadi request yang tidak pernah mencapai panel tidak membayar satu pun dari semuanya. panelSharedProps(): ComputedRef<PanelSharedProps> adalah satu-satunya cast di seluruh frontend, dan ia mengembalikan ComputedRef alih-alih snapshot karena usePage() bersifat reaktif dan props-nya berubah pada navigasi sisi klien.
Props bersama milik aplikasi host — name, auth, sidebarOpen — sengaja tidak ada di interface itu. Semuanya milik aplikasi, dan paket yang menyebut namanya berarti sedang mendeskripsikan kontrak milik orang lain.
Props halaman
Setiap jenis halaman mengirim props-nya sendiri di samping props bersama. Index resource mengirim yang paling banyak:
| Prop | Tipe | Catatan |
|---|---|---|
page | PageMetadata | judul, heading, subheading, breadcrumb, header action, scope |
resource | ResourceMeta | slug, label, URL index |
table | TableDefinition | skema yang sudah diserialisasi |
state | TableState | search, sort, direction, perPage, filters, columns, group |
rows | TableRow[] | sel, sudah diformat |
pagination | PaginationMeta | |
summaries, groupSummaries | TableSummaries | kosong untuk tabel yang tidak mendeklarasikannya |
actionEndpoints | ActionEndpoints | URL tujuan POST dari composable action |
tabs | TableTab[] | kosong untuk tabel yang tidak mendeklarasikannya |
headerWidgets, footerWidgets | WidgetDefinition[] | |
widgetData | WidgetData | null | ditunda — tidak ada di respons pertama |
Dashboard.vue dan Page.vue mengirim page, widgets, widgetData, dan (untuk dasbor) filters. Payload widget yang lazy tiba sebagai deferred prop yang di-key berdasarkan id widget, dan itulah sebabnya widgetData bersifat opsional di mana pun ia muncul.
Layout
Setiap halaman panel mendeklarasikan layout-nya sendiri, jadi tidak ada yang perlu didaftarkan di resources/js/app.ts:
defineOptions({ layout: PanelLayout }); // layar panel
defineOptions({ layout: PanelBlankLayout }); // layar auth milik panel itu sendiri2
| Layout | Peran |
|---|---|
PanelLayout | memilih shell dari panel.sidebar.variant, mendaftarkan listener error dan broadcast, me-resolve breadcrumb |
SidebarPanelLayout | shell dengan rel samping |
HeaderPanelLayout | shell dengan navigasi atas, dipakai saat ->sidebar(variant: 'header') |
PanelBlankLayout | tanpa shell |
PanelAuthLayout | layar login, register, reset, dan verifikasi milik panel |
PanelLayout menerima satu prop opsional, breadcrumbs?: PanelBreadcrumbItem[]. Default-nya diambil dari metadata halaman itu sendiri, jadi sebuah halaman tidak pernah perlu menyusun jejaknya sendiri; mengoper prop itu akan menang, untuk halaman langka yang membangunnya di sisi klien.
Satu-satunya hal yang masih bisa salah dari sisi aplikasi adalah menimpa pilihan itu di app.ts:
page.default.layout = AppLayout; // mengganti shell panel
page.default.layout ??= AppLayout; // benar2
Penetapan tanpa syarat menempatkan setiap layar panel di dalam shell milik aplikasi, dengan sidebar host dan tanpa navigasi panel di mana pun, pada HTTP 200 dan tanpa apa pun di log. panel:install membaca app.ts dan menolak selesai diam-diam ketika menemukannya, sambil menyebut berkas dan nomor barisnya.
Composable
Semuanya di bawah resources/js/panel/composables/.
| Composable | Signature | Fungsinya |
|---|---|---|
usePanel | (): UsePanelReturn | props panel bersama: panel, hasPanel, maxContentWidthClass, panels, canSwitchPanels, broadcasting, search, notifications, shell, tenancy, canSwitchTenants |
usePanelPage | (): ComputedRef<PageMetadata | null> | memvalidasi prop page, bukan meng-cast-nya |
useNavigation | (): UseNavigationReturn | groups, items, activeItem, isCollapsed(group), toggle(group) |
useResource | (resource: () => ResourceMeta, state: () => TableState): UseResourceReturn | seluruh kontrol tabel: setSearch, setSort, setPage, setPerPage, setFilter, setFilters, setColumns, resetColumns, setColumnSearch, setTab, clearFilters, nextDirectionFor |
useActions | (resourceSlug: () => string, endpoints: () => ActionEndpoints, parentKey?: () => string | number | null): UseActionsReturn | menjalankan action record, table, bulk, dan cell |
useInfolistActions | (...): UseInfolistActionsReturn | whitelist action milik halaman view |
useRelationActions | (...): UseRelationActionsReturn | action relation manager |
useRelationTable | (...): UseRelationTableReturn | state tabel milik sebuah relasi |
usePanelShell | (): UsePanelShellReturn | reloadNavigation(), reloadTopbar(), reloadShell() |
usePanelStyling | (): UsePanelStylingReturn | themeStyle, hook(name) |
usePanelBroadcasting | (): void | berlangganan channel panel; didaftarkan oleh PanelLayout |
useErrorNotifications | (): void | memetakan respons gagal menjadi toast; didaftarkan oleh PanelLayout |
useUnsavedChangesAlert | (isDirty: Ref<boolean>): void | konfirmasi meninggalkan halaman untuk form yang belum disimpan |
usePanelShell() mengambil ulang sebagian shell sebagai partial reload dari props bersama:
import { usePanelShell } from '@/panel/composables/usePanelShell';
const { reloadNavigation, reloadTopbar, reloadShell } = usePanelShell();
reloadNavigation(); // router.reload({ only: ['navigation'] })
reloadTopbar(); // router.reload({ only: ['panel', 'notifications', 'panels'] })2
3
4
5
6
Tidak ada endpoint yang menjawab "seperti apa sidebar sekarang", karena endpoint semacam itu harus me-resolve ulang panel, pengguna, dan URL agar bisa mengatakan sesuatu yang benar — dan itu persis yang sudah dilakukan sebuah request.
URL adalah state-nya
useResource menulis ke query string dan membiarkan server menjawab. Tidak ada yang menyimpan salinan lokal dari baris, sorting, atau filter:
const { setSearch, setSort, setFilter, clearFilters } = useResource(
() => props.resource,
() => props.state,
);
setSearch('ada'); // ?search=ada
setSort('name'); // ?sort=name&direction=asc
setFilter('verified', 'true'); // ?filters[verified]=true
clearFilters();2
3
4
5
6
7
8
9
Kunjungan memakai preserveState dan preserveScroll, jadi mengetik di kotak pencarian tidak menghilangkan fokus maupun posisi gulir. TanStack Table didaftarkan hanya untuk model kolom, visibilitas, dan pemilihan baris; fitur sorting, filter, dan paginasinya sengaja tidak didaftarkan, karena jawaban untuk itu datang dari server.
Registry komponen
Komponen kustom di-resolve lewat allowlist import.meta.glob saat build atas pohon berkas aplikasi itu sendiri. Nama yang tidak ikut dikompilasi tidak bisa dijangkau, apa pun kata request-nya — dan karena namanya selalu berasal dari kelas PHP yang terdaftar, bukan dari input request, glob itu adalah kunci kedua, bukan kunci pertama.
| Registry | Glob | Letakkan komponen Anda di |
|---|---|---|
| kolom | pages/Panels/**/Columns/*.vue | resources/js/pages/Panels/{Panel}/Columns/ |
| field, layout, entry, modal | pages/Panels/**/{Fields,Schemas,Entries,Modals}/*.vue | direktori yang sesuai |
| widget | pages/Panels/**/Widgets/*.vue | resources/js/pages/Panels/{Panel}/Widgets/ |
| render hook | pages/Panels/**/Hooks/*.vue | resources/js/pages/Panels/{Panel}/Hooks/ |
| pengganti shell | pages/Panels/**/Shell/*.vue | resources/js/pages/Panels/{Panel}/Shell/ |
| empty state tabel | pages/Panels/**/EmptyStates/*.vue | resources/js/pages/Panels/{Panel}/EmptyStates/ |
Key yang dikirim PHP adalah path di bawah pages/ tanpa ekstensinya:
use PandaPanel\Tables\Columns\CustomColumn;
CustomColumn::make('health')->component('Panels/Admin/Columns/HealthBar');2
3
<!-- resources/js/pages/Panels/Admin/Columns/HealthBar.vue -->Nama yang tidak dikenal merender fallback netral alih-alih melempar exception, sehingga satu komponen yang salah ketik tidak bisa menjatuhkan seluruh dasbor. Di mode development, registry-nya memberi peringatan sekali per nama di konsol sambil menyebut direktori tempat komponen itu seharusnya berada — karena ketiga penyebabnya (salah ketik, berkas di luar direktori yang di-glob, atau build yang belum dijalankan ulang) tidak bisa dibedakan dari tampilan layarnya.
Ikon memakai mekanisme berbeda: resources/js/panel/icons/registry.ts adalah berkas hasil generate, bukan glob. Tulis nama Lucide apa pun yang Anda inginkan lalu jalankan php artisan panel:icons untuk menulis ulang berkasnya; key yang tidak terdaftar sama sekali tidak merender apa pun, tanpa error.
Aturan yang menjaga frontend tetap jujur
- Tanpa
any. Union metadata dibedakan berdasarkantypedan setiap switch berakhir pada pemeriksaanneveryang exhaustive, sehingga tipe PHP baru tanpa renderer Vue menjadi error kompilasi alih-alih sel kosong. - Validasi, jangan asersi. Nilai yang menyeberang dari PHP dipersempit oleh guard —
cellGuards.ts,widgetGuards.ts,usePanelPage.ts— sehingga ketidakcocokan bentuk menurun menjadi sel kosong alih-alih melempar exception di dalam tabel.panelSharedProps()adalah satu-satunya cast yang disengaja. - Tidak menyimpan state server di state lokal. Satu-satunya state lokal adalah input pencarian yang di-debounce, nilai kerja form, pemilihan baris, dan grup navigasi mana yang sedang terlipat.
- Tanpa kelas Tailwind hasil interpolasi. Span kolom, warna badge, jumlah kolom grid, dan lebar konten semuanya dipetakan lewat record literal, karena
max-w-${token}tidak terlihat oleh compiler Tailwind dan akan diam-diam tidak ada di bundel. - Tanpa URL panel yang di-hardcode. Setiap href berasal dari server atau dari Wayfinder.
Tema dari PHP
Dua mekanisme terpisah, keduanya dibaca dari props panel bersama oleh satu composable:
$panel
->colors(
light: ['primary' => '#4f46e5'],
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
import { usePanelStyling } from '@/panel/composables/usePanelStyling';
const { themeStyle, hook } = usePanelStyling();2
3
Warna adalah nilai, jadi himpunannya terbuka dan divalidasi: propertinya harus properti yang dibaca stylesheet dan nilainya harus bisa di-parse sebagai warna, kalau tidak akan dibuang diam-diam. Hook adalah makna, jadi himpunan namanya tertutup — shell, sidebar, topbar, page, page-header, table, table-row, form, infolist, widget, modal. hook(name) selalu menyertakan kelas stabil panel-{name}, dan itulah yang dijadikan sasaran sebuah stylesheet, terlepas dari apakah panelnya berkata sesuatu atau tidak.
Kelas yang ditambahkan lewat cssHooks() harus selamat melewati build Tailwind. String bebas dari panel provider tidak ada di berkas mana pun yang dipindai Tailwind, jadi gunakan kelas yang juga muncul di tempat lain dalam aplikasi, atau tambahkan provider-nya ke glob konten.
Aset per panel
$panel->assets('resources/css/panels/admin.css');Entrypoint Vite yang dipancarkan di halaman panel itu dan tidak di tempat lain. Dua suntingan, dengan sengaja: path-nya juga harus muncul di input milik vite.config.ts, atau Vite tidak punya apa pun untuk disajikan dan halamannya gagal dengan error manifest. Kegagalan itu adalah kegagalan yang tepat — aset yang dideklarasikan tapi tidak pernah di-build memang kesalahan — tetapi itulah sebabnya ini bukan perubahan satu baris. Daftarnya tidak pernah menyeberang ke frontend; browser mendapat tag-nya, bukan apa yang menghasilkannya.
Apa yang diharapkan komponennya dari aplikasi Anda
Komponen yang dipublikasikan mengimpor sembilan belas modul yang tidak ikut dikirim paket, dan kedua jenisnya memang milik aplikasi dengan sengaja:
- Hasil generate —
@/routes/*dan@/actions/*berasal dari Wayfinder, ditulis dari tabel route Anda sendiri. Menyertakan salinannya berarti mengirim potret route milik orang lain. - Komponen starter kit —
@/components/UserMenuContent.vue,@/composables/useTwoFactorAuth, dan delapan lainnya. Di situlah sebuah proyek menyimpan tautan akun dan alur dua faktornya sendiri.
panel:install memeriksa kesembilan belasnya dan menyebut mana yang belum ada. Aplikasi Laravel Vue starter kit sudah punya semuanya; selain itu harus ditulis lebih dulu, dan frontend/host/ mendokumentasikan pengganti yang berfungsi untuk masing-masing.
Hal yang mudah terlewat
- Pola glob bersifat relatif, tidak pernah memakai alias. Dev server Vite me-resolve glob ber-alias menjadi kosong sama sekali, sementara build produksi me-resolve-nya secara normal — jadi pola ber-alias berarti setiap komponen kustom merender fallback saat development dan baru berfungsi setelah di-build.
- Komponen baru belum ada di bundel sampai
npm run build. Glob-nya dievaluasi saat build. Ini alasan paling umum kenapa widget atau kolom yang sudah ada malah merender fallback. - Render hook disaring di Vue, bukan di server. Props bersama dibangun di middleware, sebelum request mencapai halaman, jadi shell tahu halaman apa yang sedang dirender sementara middleware tidak. Setiap halaman melaporkan scope-nya sendiri di
page.scope. - Komponen yang dipublikasikan adalah milik Anda.
composer updatetidak bisa memperbaiki berkas yang kini Anda miliki. Jalankanphp artisan panel:assetsuntuk melihat apa yang tertinggal, dan--updateuntuk menulis hanya berkas yang belum pernah Anda sentuh. - Komponen panelnya hanya Vue. Separuh sisi server-nya diserialisasi menjadi array biasa dan tidak terikat framework, tetapi belum ada renderer React atau Svelte yang ditulis dan belum ada rencananya.
Baca juga
- Arsitektur sekilas — separuh sisi server dari jalur ini
- Metadata Server ke Vue dan Registry Komponen
- Pohon Komponen, Halaman Inertia
- Kolom Kustom, Field Kustom, Widget Kustom
- Hook CSS, Tema Tailwind, Ikon
- Wayfinder, Modul Host
- Memperbarui Aset