Page List, Create, View, dan Edit
Empat page standar milik Resource. Masing-masing adalah controller class nyata yang Anda subclass, daftarkan pada Resource::pages(), dan dalam banyak kasus dibiarkan hampir kosong. Halaman ini menjelaskan fungsi setiap page, method yang layak di-override, dan route yang didaftarkannya.
Set minimal
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Posts\Pages;
use App\Panels\Admin\Resources\Posts\PostResource;
use PandaPanel\Resources\Pages\ListRecords;
final class ListPosts extends ListRecords
{
protected static string $resource = PostResource::class;
}2
3
4
5
6
7
8
9
10
11
12
13
CreatePost extends CreateRecord, ViewPost extends ViewRecord, dan EditPost extends EditRecord menggunakan pola tiga baris yang sama dengan base class berbeda. Daftarkan semuanya:
/**
* @return array<string, class-string>
*/
public static function pages(): array
{
return [
'index' => ListPosts::class,
'create' => CreatePost::class,
'view' => ViewPost::class,
'edit' => EditPost::class,
];
}2
3
4
5
6
7
8
9
10
11
12
$resource adalah satu-satunya deklarasi yang wajib. Seluruh property dan method lain pada class page sudah memiliki default yang dapat bekerja.
Routes
Setiap page key mendaftarkan route dengan bentuk tetap, relatif terhadap Resource path. Route name menggunakan pola panel.{panelId}.resources.{slug}.{suffix}.
| Page key | Verb dan path | Method yang dipanggil | Suffix route name |
|---|---|---|---|
index | GET / | render | index |
create | GET create | render | create |
create | POST create | handle | store |
create | POST create/step | validateStep | validateCreateStep |
view | GET {record} | render | view |
edit | GET {record}/edit | render | edit |
edit | PUT {record}/edit | handle | update |
edit | POST {record}/edit/step | validateStep | validateEditStep |
Route diregistrasikan sebagai [PageClass::class, 'render'] dan [PageClass::class, 'handle']. Keduanya adalah controller action nyata, bukan Closure, sehingga php artisan route:cache tetap dapat digunakan.
Static segment didaftarkan sebelum wildcard {record}. Jika urutannya terbalik, /create dapat dianggap sebagai record key. Pada singular resource, segment {record} dihapus dari seluruh route tersebut.
ListRecords
Page ini merender panel/resources/Index. Tanggung jawabnya mencakup authorization, table schema, query yang dikendalikan URL, dan serialization row. ListRecords tidak membuat starting query sendiri; page selalu dimulai dari Resource::query(), sehingga Resource scope selalu berlaku.
render() menghasilkan 403 kecuali Resource::canViewAny() mengizinkan.
Tabs
use App\Models\Post;
use Illuminate\Database\Eloquent\Builder;
use PandaPanel\Resources\Pages\ListRecords;
use PandaPanel\Tables\Tab;
final class ListPosts extends ListRecords
{
protected static string $resource = PostResource::class;
/**
* @return array<string, Tab>
*/
public function tabs(): array
{
return [
'all' => Tab::make('all')->badge(static fn (): int => Post::query()->count()),
'published' => Tab::make('published')
->icon('check')
->query(static fn (Builder $query): Builder => $query->whereNotNull('published_at')),
'drafts' => Tab::make('drafts')
->query(static fn (Builder $query): Builder => $query->whereNull('published_at')),
];
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Default tabs() adalah [], yang berarti tidak ada tab. Array key menjadi value tab pada URL (?tab=drafts). Key yang tidak dikenal fallback ke tab pertama daripada menghasilkan error, sama seperti unknown sort column, karena query string adalah user input.
Tab hanya mempersempit query milik Resource, tidak pernah membuat query baru dari nol. Karena itu tenant scope dan permission scope tetap berlaku pada data yang ditampilkan tab. Widget milik page juga menerima tab-scoped query yang sama, sehingga count/summary widget mencerminkan data yang sedang dilihat user.
Override lainnya
use Illuminate\Database\Eloquent\Model;
use Illuminate\Pagination\LengthAwarePaginator;
use PandaPanel\Tables\Group;
use PandaPanel\Tables\TableSchema;
protected function headerActions(): array; // list<array<string, mixed>>
protected function rows(TableSchema $schema, LengthAwarePaginator $records, ?Group $group = null): array;
protected function pagination(LengthAwarePaginator $records): array;
protected function pageMetadata(): array;2
3
4
5
6
7
8
9
headerActions() default mengembalikan satu link "New {label}", tetapi hanya jika Resource mendeklarasikan create page dan Resource::canCreate() mengizinkan. Override menjadi [] jika list page tidak boleh menawarkan create dari header.
pagination() sengaja hanya mengirim counter — page, perPage, total, lastPage, from, to — bukan array link milik paginator. Frontend membangun URL berdasarkan current query string agar URL tetap menjadi single source of truth.
CreateRecord
Page ini merender panel/resources/Create. render() dan handle() sama-sama menghasilkan 403 kecuali Resource::canCreate() mengizinkan.
handle() menjalankan create lifecycle, memvalidasi hanya field yang dideklarasikan schema, lalu hanya mempersist field yang melakukan dehydration. Extra key pada request body dibuang daripada ikut mass assignment.
Deklarasi
protected static bool $canCreateAnother = true;
protected static bool $preservesDataOnCreateAnother = false;2
$canCreateAnother menampilkan submit action kedua yang menyimpan record lalu kembali ke form. $preservesDataOnCreateAnother menentukan apakah form berikutnya mempertahankan value yang baru diketik. Default-nya false karena use case umum adalah membuat beberapa record yang berbeda; form yang diam-diam mempertahankan record sebelumnya meningkatkan risiko record yang sama tersimpan dua kali. Frontend hanya melaporkan button mana yang diklik; arti "create another" tetap diputuskan server.
Overrides
use Illuminate\Database\Eloquent\Model;
protected function handleRecordCreation(array $attributes): Model;
protected function getRedirectUrl(Model $record): string;
protected function createdNotification(Model $record): ?array;2
3
4
5
handleRecordCreation() adalah write operation itu sendiri. Default membuat instance baru dari model, mengisinya, lalu menyimpannya. Override untuk create melalui service, factory, atau relation:
use App\Services\PostPublisher;
use Illuminate\Database\Eloquent\Model;
/**
* @param array<string, mixed> $attributes
*/
protected function handleRecordCreation(array $attributes): Model
{
return app(PostPublisher::class)->create($attributes, auth()->user());
}2
3
4
5
6
7
8
9
10
getRedirectUrl() default mengarah ke edit page jika Resource memilikinya, atau index jika tidak. createdNotification() mengembalikan ['type' => 'success', 'message' => '{Label} created.']. Return null jika page tidak ingin menampilkan notification.
use Illuminate\Database\Eloquent\Model;
protected function getRedirectUrl(Model $record): string
{
return PostResource::url('view', $record);
}
/**
* @return array{type: string, message: string}|null
*/
protected function createdNotification(Model $record): ?array
{
return ['type' => 'success', 'message' => 'Post scheduled.'];
}2
3
4
5
6
7
8
9
10
11
12
13
14
EditRecord
Page ini merender panel/resources/Edit. Record di-resolve melalui Resource::query(), sehingga record di luar Resource scope menghasilkan 404 di edit page sama seperti pada index. Authorization menggunakan canEdit() — EditRecord meng-override authorizeRecord() untuk alasan tersebut karena edit membutuhkan ability lebih kuat daripada sekadar view.
use Illuminate\Database\Eloquent\Model;
protected function authorizeRecord(Model $record): bool; // canEdit() by default
protected function handleRecordUpdate(Model $record, array $attributes): Model;
protected function getRedirectUrl(Model $record): string; // the edit page again
protected function savedNotification(Model $record): ?array;2
3
4
5
6
use Illuminate\Database\Eloquent\Model;
/**
* @param array<string, mixed> $attributes
*/
protected function handleRecordUpdate(Model $record, array $attributes): Model
{
app(PostRevisions::class)->snapshot($record);
$record->forceFill($attributes)->save();
return $record;
}2
3
4
5
6
7
8
9
10
11
12
13
Edit page juga mengirim relation tables milik record, sehingga seluruh relation manager yang dideklarasikan Resource muncul di bawah form. Lihat Relation managers.
ViewRecord
Page ini merender panel/resources/View, bersifat read-only, dan di-authorize menggunakan canView().
Content berasal dari satu dari dua sumber. Jika Resource mendeklarasikan infolist, infolist tersebut dirender. Jika tidak, page membentuk entries dari FormSchema yang sama dengan edit page sehingga field yang ditambahkan sekali dapat muncul pada kedua surface. Password field dikeluarkan dari derived entries karena tidak memiliki value meaningful untuk ditampilkan dan merender stored hash jelas tidak aman.
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Forms\Components\Field;
protected function entries(Model $record): array; // the form-derived fallback
protected function displayValue(Field $field, Model $record): ?string; // stringified server-side
protected function headerActions(Model $record): array;2
3
4
5
6
headerActions() default mengembalikan satu link "Edit", tetapi hanya ketika Resource mendeklarasikan edit page dan canEdit() mengizinkan untuk record tersebut.
Begitu view page memiliki struktur yang lebih serius, gunakan infolist daripada terus bergantung pada form-derived fallback:
use PandaPanel\Infolists\InfolistSchema;
public static function infolist(InfolistSchema $schema): InfolistSchema
{
return PostInfolist::configure($schema);
}2
3
4
5
6
Lihat Infolists.
Behavior yang dimiliki bersama
Keempat page meng-extend PandaPanel\Resources\Pages\ResourcePage.
Titles dan headings
protected static ?string $title = null;
protected static ?string $heading = null;
protected static ?string $subheading = null;
public function getTitle(?Model $record = null): string;
public function getHeading(?Model $record = null): string;
public function getSubheading(?Model $record = null): ?string;2
3
4
5
6
7
| Page | title | heading | subheading |
|---|---|---|---|
ListRecords | Plural label | title | — |
CreateRecord | New {label} | title | — |
ViewRecord | Record title | title | label |
EditRecord | Edit {record} | Record title | Edit {label} |
ManageRelatedRecords | Manager title | title | Owner record title |
Heading mengikuti title kecuali page sengaja memisahkannya. Edit page adalah contoh utama: breadcrumb sudah menjelaskan bahwa page sedang berada pada mode edit, sehingga menaruh verb "Edit" sekali lagi pada heading record akan terasa repetitif.
Gunakan static property ketika text selalu tetap; override getter ketika text bergantung pada record:
use Illuminate\Database\Eloquent\Model;
public function getSubheading(?Model $record = null): ?string
{
return $record === null ? null : 'Editing '.$record->getAttribute('email');
}2
3
4
5
6
Record diberikan pada page yang memang memiliki record dan bernilai null pada page yang tidak. Getter harus mampu menangani kedua kondisi.
Transactions
protected static ?bool $hasDatabaseTransactions = null;null berarti mengikuti Panel, yang default-nya mengaktifkan transaction. Set menjadi true atau false pada page yang write behavior-nya memang perlu berbeda dari Resource lain — misalnya page yang juga melakukan external service call dan tidak ingin menahan database transaction terlalu lama. Persist step dan hook after* berbagi transaction tersebut. Lihat Lifecycle hooks.
Widgets
/**
* @return list<class-string<Widget>>
*/
public function headerWidgets(): array
{
return [PostsThisWeek::class];
}
/**
* @return list<class-string<Widget>>
*/
public function footerWidgets(): array
{
return [];
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
Keduanya default []. Widget pada list page menerima query milik page sebagai context. Widget pada record page menerima record. Lihat Widgets.
Route path dan render hook
protected static ?string $routePath = null;
public static function routePath(string $key): string; // defaults to the page key
public static function renderHookScope(): string; // 'resource:{slug}'
public static function resource(): string; // class-string<Resource>
public static function hasDatabaseTransactions(): ?bool;2
3
4
5
6
$routePath hanya digunakan custom page. Empat page key standar memiliki route shape tetap. Render hook scope menggunakan slug, bukan class name; page metadata tidak membocorkan PHP class name ke frontend. Lihat Render hooks.
Wizards
Form yang dibagi menjadi beberapa step memvalidasi satu step pada saat user bergerak ke step berikutnya. validateStep() tersedia pada create dan edit page dan memiliki route create/step serta {record}/edit/step. Rule diturunkan dari schema yang sama dengan full form lalu dipersempit ke field yang memang dimiliki step. Tidak ada definisi validation kedua yang dapat drift. Page tanpa Wizard menghasilkan 400 jika step-validation endpoint dipanggil.
Tidak ada wiring tambahan. Mendeklarasikan Wizard di FormSchema sudah cukup; page hanya mengirim validateStepUrl jika benar-benar ada Wizard. Lihat Form layouts.
Catatan penting
render()menangani GET,handle()menangani write. Keduanya ordinary controller method sehingga route Panel tetap cacheable.- Create page yang fill hook-nya melakukan halt akan redirect ke index daripada merender form setengah terisi. Halt saat
handle()mengembalikan user ke page sebelumnya tanpa menulis data. - Create page melakukan POST ke route
store, bukan routecreate.Resource::url('store')digunakan sebagai submit URL, sedangkanResource::url('create')adalah URL page. ViewRecordhanya memakai form-derived entries selama Resource tidak mendeklarasikan infolist. Setelahinfolist()memberikan schema,entriestidak lagi menjadi sumber authoritative.- Hanya empat key standar yang memiliki fixed route. Key lain pada
pages()dianggap custom page dengan satu GET route. Lihat Resource pages.