Referensi Resources
PandaPanel\Resources\Resource merupakan fondasi utama untuk membangun fitur CRUD berbasis model di dalam PandaBear.
Resource menghubungkan:
- Eloquent Model;
- Table;
- Form;
- Infolist;
- Resource Pages;
- authorization;
- routing;
- global search;
- Relation Manager;
- widgets;
- integrations;
- tenancy;
- nested resources;
- dan konfigurasi per-Panel.
Gunakan halaman ini ketika Anda perlu mencari:
- signature method;
- default property;
- mekanisme query;
- cara record di-resolve;
- route Resource;
- lifecycle Page;
- atau behavior internal Resource.
Untuk panduan mengenai kapan menggunakan masing-masing fitur, lihat:
Namespaces
| Class | Kegunaan |
|---|---|
PandaPanel\Resources\Resource | Base class yang di-extend oleh Resource |
PandaPanel\Resources\ResourceConfiguration | Konfigurasi satu Resource untuk satu Panel |
PandaPanel\Resources\Pages\ResourcePage | Base class seluruh Resource Page |
PandaPanel\Resources\Pages\ListRecords | Halaman index/list |
PandaPanel\Resources\Pages\CreateRecord | Halaman create |
PandaPanel\Resources\Pages\ViewRecord | Halaman detail read-only |
PandaPanel\Resources\Pages\EditRecord | Halaman edit |
PandaPanel\Resources\Pages\ManageRelatedRecords | Page khusus untuk mengelola satu relation |
PandaPanel\Resources\Concerns\HasLifecycleHooks | Lifecycle hooks yang dapat dioverride Page |
PandaPanel\Resources\Concerns\InteractsWithRecord | Record resolution untuk custom Resource Page |
PandaPanel\Resources\RelationManager | Satu relation yang dikelola di bawah record |
PandaPanel\Search\GlobalSearch | Service di balik global command palette |
PandaPanel\Support\ParentRecord | Parent record yang sudah di-bind untuk nested Resource |
Resource Minimum yang Berfungsi
<?php
namespace App\Panels\Admin\Resources\Posts;
use App\Models\Post;
use App\Panels\Admin\Resources\Posts\Pages\ListPosts;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\FormSchema;
use PandaPanel\Resources\Resource;
use PandaPanel\Tables\Columns\TextColumn;
use PandaPanel\Tables\TableSchema;
final class PostResource extends Resource
{
protected static string $model =
Post::class;
public static function table(
TableSchema $table
): TableSchema {
return $table
->columns([
TextColumn::make(
'title'
)
->searchable()
->sortable(),
]);
}
public static function form(
FormSchema $schema
): FormSchema {
return $schema
->schema([
TextInput::make(
'title'
)
->required(),
]);
}
public static function pages(): array
{
return [
'index' =>
ListPosts::class,
];
}
}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
46
47
48
49
50
Page index:
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
Jika Resource berada pada discovery path Panel dengan path:
/adminmaka route-nya menjadi:
/admin/postsdengan route name:
panel.admin.resources.posts.indexResource — Declarations
Semua property berikut bersifat:
protected staticSetiap property memiliki reader sendiri.
Bagian framework lain membaca konfigurasi melalui reader tersebut, bukan langsung melalui property.
| Property | Type | Default | Reader |
|---|---|---|---|
$model | class-string<Model> | wajib | getModel() |
$slug | ?string | plural kebab-case dari basename model | defaultSlug(), slug() |
$label | ?string | headline basename model | defaultLabel(), label() |
$pluralLabel | ?string | plural dari label | defaultPluralLabel(), pluralLabel() |
$recordTitleAttribute | ?string | 'name' | recordTitle() |
$navigationLabel | ?string | plural label | navigationItem() |
$navigationIcon | ?string | null | navigationIcon() |
$activeNavigationIcon | ?string | navigation icon | activeNavigationIcon() |
$navigationGroup | string|BackedEnum|null | null | navigationItem() |
$navigationSort | int | 0 | navigationItem() |
$shouldRegisterNavigation | bool | true | navigationItem() |
$cluster | ?class-string<Cluster> | null | cluster() |
$subNavigationPosition | ?SubNavigationPosition | null, mengikuti Panel | subNavigationPosition() |
$globalSearchAttributes | list<string> | [] | globalSearchAttributes() |
$globalSearchLimit | int | 5 | globalSearchLimit() |
$globalSearchSort | int | 0 | globalSearchSort() |
$singular | bool | false | isSingular() |
$with | list<string> | [] | diterapkan di query() |
$softDeletes | bool | false | usesSoftDeletes() |
$parentResource | ?class-string<Resource> | null | parentResource(), isNested() |
$parentRelationship | ?string | camelCase dari default slug | parentRelationship() |
$tenantRelationship | ?string | null | tenantRelationship() |
$model Wajib Dideklarasikan
Contoh:
protected static string $model =
Post::class;2
Jika tidak dideklarasikan:
getModel()melempar:
PanelSchemaException::missingModel()Framework menggunakan exception khusus karena error bawaan PHP biasanya hanya mengatakan:
Typed static property
PandaPanel\Resources\Resource::$model
must not be accessed before initialization2
3
Pesan tersebut menunjuk base class, bukan Resource yang sebenarnya lupa menentukan model.
Resource — Schema Methods
API utama:
abstract public static function table(
TableSchema $table
): TableSchema;
abstract public static function form(
FormSchema $schema
): FormSchema;
abstract public static function pages(): array;
// array<string, class-string>
public static function infolist(
InfolistSchema $schema
): InfolistSchema;
// default mengembalikan schema tanpa perubahan
public static function relationManagers(): array;
// []
public static function relationManager(
string $key
): ?string;
public static function integrations(
Integrations $integrations
): Integrations;
// default tanpa perubahan
public static function integrationSettings():
Integrations;
// resolved sekali per class per request
public static function getWidgets(): array;
// []
public static function getHeaderWidgets(
string $page
): array;
// index menggunakan getWidgets()
public static function getFooterWidgets(
string $page
): array;
// []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
Mengapa form() Abstract
form() sengaja tidak memiliki empty default.
Resource yang tidak memiliki Form harus menyatakannya secara eksplisit melalui desain Resource/Page-nya.
Framework tidak ingin Resource secara tidak sengaja mendapatkan create/edit flow dengan schema kosong yang kemudian menyimpan tidak ada data.
infolist() Optional
Default:
public static function infolist(
InfolistSchema $schema
): InfolistSchema {
return $schema;
}2
3
4
5
Jika Resource tidak mendeklarasikan Infolist khusus:
ViewRecordmembuat read-only representation dari Form Schema.
Dengan demikian custom Infolist merupakan improvement yang dapat ditambahkan kemudian.
relationManager()
Method:
relationManager($key)mencari Relation Manager berdasarkan key.
Jika dua manager memiliki key sama:
PanelRegistrationException::duplicateRelationKey()dilempar.
Relation key harus unik dalam satu Resource.
Resource — Query
API:
public static function query():
Builder;
protected static function recordQuery():
Builder;
protected static function applyTenantScope(
Builder $query
): Builder;
protected static function parentRelation():
Relation;2
3
4
5
6
7
8
9
10
11
12
query() adalah Satu Entry Point Utama
Semua operation utama menggunakan:
Resource::query()Termasuk:
- list;
- view;
- edit;
- update;
- delete;
- bulk operation;
- Action record lookup;
- global search.
Karena itu Resource scope harus diletakkan di jalur query ini.
Urutan query()
Secara konsep:
Resource::query()
↓
nested?
├── yes
│ ↓
│ parentRelation()->getQuery()
│
└── no
↓
Model::query()
↓
eager load $with
↓
Panel menggunakan tenancy
dan Resource memiliki
$tenantRelationship?
↓
apply tenant scope
↓
ResourceConfiguration
::modifyQueryUsing()
↓
Final Builder2
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
Secara urut:
- nested Resource mulai dari:
parentRelation()->getQuery()sedangkan Resource biasa mulai dari:
Model::query()relation di
$withdi-eager-load;tenant scope diterapkan jika:
- Panel menggunakan tenancy;
- Resource memiliki
$tenantRelationship;
konfigurasi per-Panel melalui:
ResourceConfiguration::modifyQueryUsing()diterapkan.
Override query() Harus Memanggil Parent
Contoh benar:
public static function query():
Builder
{
return parent::query()
->where(
'team_id',
auth()
->user()
->team_id
);
}2
3
4
5
6
7
8
9
10
11
Jangan:
return Post::query()
->where(...);2
jika Anda masih ingin mempertahankan:
- tenant scope;
- nested parent scope;
- per-Panel query modifier;
- eager loading framework.
Mengabaikan:
parent::query()dapat secara diam-diam menghilangkan narrowing yang sudah diterapkan Panel.
recordQuery()
recordQuery() menggunakan:
query()tetapi menghapus:
SoftDeletingScopedan hanya scope tersebut.
Scope lain tetap aktif:
- tenant;
- parent;
- Panel configuration;
- permission/module scopes.
Ini memungkinkan trashed record dibuka untuk:
- View;
- Restore;
- Force Delete;
tanpa membuat index otomatis menampilkan record yang sudah dihapus.
Resource — Record Lookup
API:
public static function resolveRecord(
int|string $key
): Model;
// findOrFail → 404
public static function findRecord(
int|string $key
): ?Model;
public static function findRecords(
array $keys
): Collection;
// list<int|string>
// => Collection<int, Model>
public static function resolveSingularRecord():
Model;
// query()->firstOrFail()
public static function recordTitle(
Model $record
): string;2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Semua method record lookup menggunakan:
recordQuery()Artinya record di luar Resource scope menghasilkan:
404bukan data leak.
Contoh:
PostResource::findRecord(7);
// ?Post2
Bulk:
PostResource::findRecords([
1,
2,
]);
// Collection<int, Post>2
3
4
5
Singular Resource
Singular Resource tidak merepresentasikan daftar record.
Contohnya:
Application Settings
Company Profile
Current Tenant Configuration2
3
Jika record seharusnya dibuat otomatis ketika pertama kali diakses, override:
public static function resolveSingularRecord():
Model
{
return static::query()
->firstOrCreate([]);
}2
3
4
5
6
Resource — Authorization
API:
public static function canViewAny(): bool;
// 'viewAny' pada model class
public static function canView(
Model $record
): bool;
public static function canCreate(): bool;
public static function canEdit(
Model $record
): bool;
// ability 'update'
public static function canDelete(
Model $record
): bool;
public static function canDeleteAny():
bool;
public static function canRestore(
Model $record
): bool;
public static function canForceDelete(
Model $record
): bool;
public static function canRestoreAny():
bool;
public static function canForceDeleteAny():
bool;
protected static function authorize(
string $ability,
Model|string $argument
): bool;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
Semua Authorization Melalui authorize()
Seluruh method:
canViewAny
canView
canCreate
canEdit
canDelete
canDeleteAny
canRestore
canForceDelete
canRestoreAny
canForceDeleteAny2
3
4
5
6
7
8
9
10
berakhir pada:
authorize()yang menggunakan:
PandaPanel\Support\PolicyGate::allows()Override Semua Ability
Jika ingin mengubah authorization behavior secara global pada Resource:
protected static function authorize(
string $ability,
Model|string $argument
): bool
{
// ...
}2
3
4
5
6
7
dapat dioverride.
Override Satu Ability
Jika hanya ingin mengubah satu ability:
public static function canEdit(
Model $record
): bool {
return $record->status !== 'locked'
&& static::authorize(
'update',
$record
);
}2
3
4
5
6
7
8
9
Tetap gunakan:
static::authorize()agar:
strictAuthorization()tetap bekerja.
Strict Authorization
Jika Panel menggunakan:
Panel::strictAuthorization()maka:
missing Policyatau:
missing Policy methodmenghasilkan:
PanelAuthorizationExceptiondaripada hanya silent denial.
Navigation Authorization Bukan Satu-satunya Boundary
Method can*() digunakan untuk:
- navigation visibility;
- Page checks;
- Action factory authorization.
Namun route dan Action endpoint tetap melakukan authorization sendiri.
Prinsipnya:
button hidden
≠
permission enforcement2
3
Resource — Slugs, Labels, dan URLs
API:
public static function defaultSlug():
string;
public static function slug(): string;
// slugIn(panel())
public static function slugIn(
?Panel $panel
): string;
public static function defaultLabel():
string;
public static function label(): string;
public static function defaultPluralLabel():
string;
public static function pluralLabel():
string;
public static function configurationIn(
?Panel $panel
): ?ResourceConfiguration;
public static function routeName(
string $page = 'index',
Panel|string|null $panel = null
): string;
public static function url(
string $page = 'index',
Model|int|string|null $record = null,
Panel|string|null $panel = null,
Model|int|string|null $parent = null,
): string;
protected static function resolvePanel(
Panel|string|null $panel
): Panel;
protected static function assertRegisteredIn(
Panel $panel
): void;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
Panel Memiliki Effective Slug
Slug efektif Resource dimiliki oleh Panel registry, bukan mutlak oleh Resource class.
Resource class memiliki:
defaultSlug()sebagai fallback.
Namun per-Panel configuration dapat mengubahnya.
Contohnya:
Admin Panel
UserResource
→ users
CRM Panel
UserResource
→ people2
3
4
5
6
7
Class yang sama dapat memiliki slug berbeda di Panel berbeda.
defaultSlug()
Registry membutuhkan default slug sebelum effective configuration selesai dibentuk.
Karena itu:
defaultSlug()tidak boleh membaca registry kembali.
Ini mencegah circular resolution.
URL Examples
PostResource::url();
// '/admin/posts'2
Edit:
PostResource::url(
'edit',
$post
);
// '/admin/posts/7/edit'2
3
4
5
Resource sama pada Panel lain:
PostResource::url(
'index',
panel: 'app'
);2
3
4
Nested Resource:
CommentResource::url(
'edit',
$comment,
parent: $post
);2
3
4
5
URL Selalu Berdasarkan Route Name
url() membangun URL berdasarkan registered route name.
URL selalu relatif:
absolute: falseJika Resource tidak terdaftar pada Panel yang diminta:
PanelRegistrationException::resourceNotInPanel()Jika dipanggil di luar Panel tanpa explicit Panel:
PanelRegistrationException::noCurrentPanel()Nested Resource URL
Ketika sedang berada di dalam nested Resource request, parent dapat diambil melalui:
ParentRecord::require()Karena itu link antar-Page dari nested Resource yang sama tidak selalu membutuhkan argument:
parent:secara eksplisit.
Resource — Global Search
API:
public static function globalSearchAttributes():
array;
// []
public static function isGloballySearchable():
bool;
// attributes !== []
public static function globalSearchLimit():
int;
// 5
public static function globalSearchSort():
int;
// 0
public static function globalSearchQuery():
Builder;
// query()
public static function globalSearchResultTitle(
Model $record
): string;
public static function globalSearchResultDetails(
Model $record
): array;
// []
public static function globalSearchResultUrl(
Model $record
): string;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
Global Search adalah Opt-In
Default:
protected static array
$globalSearchAttributes = [];2
Artinya Resource tidak ikut Global Search sampai secara eksplisit mendeklarasikan attribute.
Contoh:
protected static array
$globalSearchAttributes = [
'name',
'email',
];2
3
4
5
Prinsipnya:
Menambahkan Resource ke Panel tidak boleh secara otomatis memperluas data yang dapat dijangkau Global Search.
Search Result Details
Contoh:
public static function globalSearchResultDetails(
Model $record
): array {
return [
'Email' =>
(string)
$record->getAttribute(
'email'
),
];
}2
3
4
5
6
7
8
9
10
11
Details yang dikirim ke Vue berupa:
label
→ scalar value2
Search Result URL
globalSearchResultUrl() memilih tujuan dengan prioritas:
view page
↓
edit page
↓
index page2
3
4
5
Setiap target route tetap melakukan authorization ketika dibuka.
Resource — Navigation
API:
public static function navigationItem(
PanelContract $panel
): ?NavigationItem;2
3
Method mengembalikan:
nulljika:
- Resource adalah nested Resource;
- Resource tidak memiliki
indexPage; $shouldRegisterNavigationfalse;- per-Panel configuration menonaktifkan navigation.
Mengapa Nested Resource Tidak Ada di Sidebar
Nested Resource membutuhkan parent record.
Contohnya:
Projects
└── Tasks2
Sidebar tidak memiliki context:
Project mana?Karena itu nested Resource tidak memiliki standalone navigation entry.
Resource Tanpa Index Tidak Ada di Sidebar
Jika Resource tidak memiliki:
indexmaka framework tidak membuat navigation link.
Membuat link ke route yang tidak pernah didaftarkan akan menyebabkan error ketika sidebar dirender.
ResourceConfiguration
ResourceConfiguration digunakan untuk mengkonfigurasi Resource yang sama secara berbeda pada Panel yang berbeda.
API:
public static function for(
string $resource
): self;
public function slug(
string $slug
): self;
public function label(
string $label
): self;
public function pluralLabel(
string $pluralLabel
): self;
public function navigationLabel(
string $navigationLabel
): self;
public function navigationGroup(
?string $navigationGroup
): self;
public function navigationIcon(
?string $navigationIcon
): self;
public function navigationSort(
int $navigationSort
): self;
public function registerNavigation(
bool $register = true
): self;
public function modifyQueryUsing(
Closure $callback
): self;
public function getSlug(): string;
public function getLabel(): string;
public function getPluralLabel():
string;
public function getNavigationLabel():
?string;
public function getNavigationGroup():
?string;
public function getNavigationIcon():
?string;
public function getNavigationSort():
?int;
public function shouldRegisterNavigation():
?bool;
public function applyQuery(
Builder $query
): Builder;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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
Contoh Per-Panel Configuration
use PandaPanel\Resources\ResourceConfiguration;
$panel->resources([
ResourceConfiguration::for(
UserResource::class
)
->slug('people')
->pluralLabel(
'People'
)
->navigationGroup(
'Directory'
)
->modifyQueryUsing(
fn (
Builder $query
) =>
$query->where(
'is_admin',
false
)
),
]);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Pada Panel tersebut:
UserResource
↓
slug = people
plural label = People
group = Directory
query excludes admins2
3
4
5
6
Query Modifier Mempengaruhi Seluruh Resource
modifyQueryUsing() diterapkan di dalam:
Resource::query()Karena itu narrowing tersebut memengaruhi:
- list;
- view;
- edit;
- actions;
- bulk;
- global search;
- relation lookup yang menggunakan Resource query.
Resource Configuration Tidak Diregistrasikan Dua Kali
Jika Resource sudah didaftarkan menggunakan:
ResourceConfigurationPanelManager tidak mendaftarkan class yang sama sekali lagi dalam bentuk bare Resource.
Ini mencegah Resource mengklaim:
configured slug
+
default slug2
3
sekaligus.
ResourcePage
ResourcePage merupakan base class seluruh Page milik Resource.
GET route menggunakan:
[
PageClass::class,
'render',
]2
3
4
Write route menggunakan:
'handle'Dengan demikian Resource Page merupakan controller nyata.
Tidak ada route closure, sehingga:
php artisan route:cachetetap kompatibel.
ResourcePage Declarations
| Property | Type | Default |
|---|---|---|
$resource | class-string<Resource> | wajib |
$hasDatabaseTransactions | ?bool | null, inherit Panel |
$routePath | ?string | null, menggunakan Page key |
$title | ?string | null, menggunakan default Page |
$heading | ?string | null, mengikuti title |
$subheading | ?string | null |
ResourcePage Public API
public function getTitle(
?Model $record = null
): string;
public function getHeading(
?Model $record = null
): string;
public function getSubheading(
?Model $record = null
): ?string;
public static function routePath(
string $key
): string;
public static function hasDatabaseTransactions():
?bool;
public static function renderHookScope():
string;
// 'resource:{slug}'
public static function resource():
string;
public function headerWidgets():
array;
// Resource::getHeaderWidgets($page)
public function footerWidgets():
array;
// Resource::getFooterWidgets($page)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
Dynamic Heading
Jika heading membutuhkan record, override default heading method.
Contoh:
protected function defaultHeading(
?Model $record
): string {
return $record === null
? 'Orders'
: "Order #{$record->number}";
}2
3
4
5
6
7
Protected Helpers untuk Custom Resource Page
protected function defaultTitle(
?Model $record
): string;
protected function defaultHeading(
?Model $record
): string;
protected function defaultSubheading(
?Model $record
): ?string;
protected function headingMetadata(
?Model $record = null
): array;
protected function panel(): Panel;
protected function dashboardUrl():
string;
protected function baseBreadcrumbs():
array;
protected function parentBreadcrumbs():
array;
protected function recordCrumb(
Model $record,
string $title
): Breadcrumb;
protected function serializeBreadcrumbs(
array $crumbs
): array;
protected function resourceMetadata():
array;
protected function recordTitle(
Model $record
): string;
protected function actionEndpoints():
array;
protected function clusterNavigation():
?array;
protected function subNavigation(
?Model $record,
string $currentPage
): array;
protected function subNavigationPosition():
SubNavigationPosition;
protected function relationTables(
Request $request,
Model $record
): array;
protected function widgetProps(
?PageContext $context = null
): array;
protected function fillForm(
FormSchema $schema,
?Model $record = null
): array;
protected function validateStepFor(
Request $request,
FormSchema $schema,
?Model $record = null
): JsonResponse;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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
ListRecords
Component:
protected static string $component =
'panel/resources/Index';2
API:
public function render(
Request $request
): Response;
public function tabs(): array;
// array<string, Tab>
// default []
protected function pageMetadata():
array;
protected function headerActions():
array;
protected function rows(
TableSchema $schema,
LengthAwarePaginator $records,
?Group $group = null
): array;
protected function pagination(
LengthAwarePaginator $records
): array;2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
ListRecords::render()
Flow:
Request
↓
canViewAny()?
↓
403 jika tidak
↓
Resource::table()
↓
TableSchema
↓
TableQuery
↓
Resource::query()
↓
Pagination
↓
Rows
↓
Inertia2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
TableQuery di-key berdasarkan:
Panel
+
Resource slug2
3
bukan arbitrary value dari request.
Tabs pada ListRecords
Contoh:
use PandaPanel\Tables\Tab;
public function tabs(): array
{
return [
'all' =>
Tab::make(
'all',
'All'
),
'draft' =>
Tab::make(
'draft',
'Draft'
)
->query(
fn (
Builder $query
) =>
$query->where(
'status',
'draft'
)
)
->badge(
fn () =>
Post::where(
'status',
'draft'
)->count()
),
];
}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
Unknown Tab
Jika request menggunakan:
?tab=does-not-existframework fallback ke tab pertama.
Query string dianggap user input.
Behavior serupa digunakan pada unknown sort column.
List Header Action
Default headerActions() menghasilkan Create link jika:
- Resource memiliki Page:
createcanCreate()menghasilkan true.
Pagination
pagination() hanya mengirim counter dan metadata.
Framework tidak mengirim array paginator URL dari Laravel.
Frontend membangun URL pagination sendiri berdasarkan current query string.
Ini menjaga search/filter/sort state tetap konsisten.
CreateRecord
Declarations:
protected static string $component =
'panel/resources/Create';
protected static string $page =
'create';
protected static bool
$canCreateAnother =
true;
protected static bool
$preservesDataOnCreateAnother =
false;2
3
4
5
6
7
8
9
10
11
12
13
API:
public function render(
Request $request
): Response;
public function handle(
Request $request
): RedirectResponse;
public function validateStep(
Request $request
): JsonResponse;
protected function handleRecordCreation(
array $attributes
): Model;
protected function getRedirectUrl(
Model $record
): string;
protected function createdNotification(
Model $record
): ?array;
protected function schema():
FormSchema;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
Create Lifecycle
handle() menjalankan:
Request
↓
beforeValidate()
↓
validation
↓
afterValidate()
↓
beforeCreate()
↓
mutateFormDataBeforeCreate()
↓
mutateFormDataBeforeSave()
↓
beforeSave()
↓
dehydrate()
↓
transaction
↓
handleRecordCreation()
↓
saveRelations()
↓
afterCreate()
↓
afterSave()
↓
redirect2
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
Hanya field yang dideklarasikan schema yang divalidasi.
Hanya field yang melakukan dehydration yang dipersist.
Extra key dari request tidak masuk ke model secara otomatis.
Custom Record Creation
Contoh:
protected function handleRecordCreation(
array $attributes
): Model {
return app(
CreateOrder::class
)->run(
$attributes
);
}2
3
4
5
6
7
8
9
Ini cocok jika create membutuhkan domain service sendiri.
Custom Redirect
protected function getRedirectUrl(
Model $record
): string {
return static::$resource::url(
'view',
$record
);
}2
3
4
5
6
7
8
Custom Created Notification
protected function createdNotification(
Model $record
): ?array {
return [
'type' =>
'success',
'message' =>
'Order placed.',
];
}2
3
4
5
6
7
8
9
10
11
Untuk tidak menampilkan message:
return null;EditRecord
Declarations:
protected static string $component =
'panel/resources/Edit';
protected static string $page =
'edit';2
3
4
5
API:
public function render(
Request $request,
?string $record = null
): Response;
public function handle(
Request $request,
?string $record = null
): RedirectResponse;
public function validateStep(
Request $request,
?string $record = null
): JsonResponse;
protected function authorizeRecord(
Model $record
): bool;
// canEdit()
protected function handleRecordUpdate(
Model $record,
array $attributes
): Model;
protected function getRedirectUrl(
Model $record
): string;
// default kembali ke edit
protected function savedNotification(
Model $record
): ?array;
protected function schema():
FormSchema;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
Wizard Validation pada Edit
validateStep() selalu me-resolve record terlebih dahulu.
Artinya framework tidak memvalidasi step form terhadap record yang tidak boleh diedit user.
Urutannya:
resolve record
↓
authorize edit
↓
build schema
↓
validate wizard step2
3
4
5
6
7
ViewRecord
Declarations:
protected static string $component =
'panel/resources/View';
protected static string $page =
'view';2
3
4
5
API:
public function render(
Request $request,
?string $record = null
): Response;
protected function entries(
Model $record
): array;
protected function displayValue(
Field $field,
Model $record
): ?string;
protected function headerActions(
Model $record
): array;2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ViewRecord dan Infolist
Jika Resource memiliki custom Infolist:
Resource::infolist()
↓
render Infolist2
3
Jika tidak:
Resource::form()
↓
derive read-only entries2
3
Password Tidak Ditampilkan pada Form-Derived View
Jika fallback menggunakan Form Schema, field:
PasswordInputdilewati.
Framework tidak pernah mencoba menampilkan stored password hash sebagai read-only value.
ManageRelatedRecords
Declarations:
protected static string $component =
'panel/resources/ManageRelated';
protected static string
$relationManager;
// class-string<RelationManager>
protected static string $page =
'relation';2
3
4
5
6
7
8
9
API:
public function render(
Request $request,
?string $record = null
): Response;
public static function relationManager():
string;
public static function routePath(
string $key
): string;
// '{record}/'.$key
public static function relationPageKey():
string;
// key milik Relation Manager2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Contoh Manage Related Page
final class ManageUserPosts
extends ManageRelatedRecords
{
protected static string $resource =
UserResource::class;
protected static string
$relationManager =
PostsRelationManager::class;
}2
3
4
5
6
7
8
9
10
Daftarkan pada Resource:
public static function pages(): array
{
return [
'index' =>
ListUsers::class,
'edit' =>
EditUser::class,
'posts' =>
ManageUserPosts::class,
];
}2
3
4
5
6
7
8
9
10
11
12
13
Route default menjadi:
{record}/postsRelation Manager Harus Dideklarasikan Resource
Saat render():
Page
↓
relationManager class
↓
Resource::relationManagers()
↓
manager ditemukan?2
3
4
5
6
7
Jika tidak:
404ManageRelatedRecords tidak menjadi registration kedua untuk Relation Manager.
Page hanya menunjuk ke manager yang sudah dideklarasikan Resource.
HasLifecycleHooks
Concern:
PandaPanel\Resources\Concerns\HasLifecycleHooksdigunakan Resource Page.
Semua hook memiliki default no-op dan benar-benar dipanggil framework.
API:
protected function halt(): never;
// throws Halt
protected function beforeFill():
void;
protected function mutateFormDataBeforeFill(
array $data
): array;
protected function afterFill(
array $data
): void;
protected function beforeValidate(
array $input
): array;
protected function afterValidate(
array $data
): array;
protected function beforeCreate():
void;
protected function mutateFormDataBeforeCreate(
array $data
): array;
protected function mutateFormDataBeforeSave(
array $data,
?Model $record
): array;
protected function beforeSave(
?Model $record
): void;
protected function afterCreate(
Model $record
): void;
protected function afterSave(
Model $record
): void;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
Lifecycle Render Form
Urutan:
beforeFill
↓
mutateFormDataBeforeFill
↓
afterFill2
3
4
5
Lifecycle Create
beforeValidate
↓
validate
↓
afterValidate
↓
beforeCreate
↓
mutateFormDataBeforeCreate
↓
mutateFormDataBeforeSave
↓
beforeSave
↓
handleRecordCreation
↓
afterCreate
↓
afterSave2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Lifecycle Update
beforeValidate
↓
validate
↓
afterValidate
↓
mutateFormDataBeforeSave
↓
beforeSave
↓
handleRecordUpdate
↓
afterSave2
3
4
5
6
7
8
9
10
11
12
13
Dua Jenis Hook
Hook dengan prefix:
mutate*menerima data dan harus mengembalikan data.
Contoh:
protected function mutateFormDataBeforeCreate(
array $data
): array {
$data['created_by'] =
auth()->id();
return $data;
}2
3
4
5
6
7
8
Hook lain digunakan untuk:
- side effects;
- validation tambahan;
- halt lifecycle.
Dan tidak mengembalikan transformed data.
halt()
Contoh:
protected function beforeCreate():
void
{
if (
Order::whereDate(
'created_at',
today()
)->count() >= 100
) {
$this->halt();
}
}2
3
4
5
6
7
8
9
10
11
12
halt() menghentikan lifecycle sebelum write terjadi.
Page menangkap exception:
Haltlalu mengembalikan user ke lokasi yang sesuai.
Halt bukan HTTP exception dan tidak menghasilkan 500.
Transaction dan Lifecycle
Persistence serta hook:
afterCreate
afterSave2
berjalan dalam transaction yang sama dengan write.
Jika hook melempar exception:
record write
+
after hook
↓
exception
↓
rollback2
3
4
5
6
7
Delete Tidak Menggunakan Page Lifecycle Hooks
Delete dilakukan melalui Action endpoint.
Tidak ada Page instance saat Delete Action berjalan.
Karena itu gunakan:
Action::before()dan:
Action::after()untuk delete/action lifecycle.
Hook tersebut menggunakan Action transaction.
InteractsWithRecord
Concern ini digunakan untuk custom Resource Page yang route-nya memiliki:
{record}Built-in user:
ViewRecord;EditRecord;ManageRelatedRecords.
API:
protected function resolveRecord(
int|string|null $key = null
): Model;
protected function getRecord():
Model;
// LogicException jika belum resolveRecord()
protected function hasRecord():
bool;
protected function authorizeRecord(
Model $record
): bool;
// default canView()2
3
4
5
6
7
8
9
10
11
12
13
14
15
Custom Resource Page dengan Record
Contoh:
use PandaPanel\Resources\Concerns\InteractsWithRecord;
use PandaPanel\Resources\Pages\ResourcePage;
final class OrderTimeline
extends ResourcePage
{
use InteractsWithRecord;
protected static string $resource =
OrderResource::class;
protected static ?string $routePath =
'{record}/timeline';
public function render(
Request $request,
?string $record = null
): Response {
$order =
$this->resolveRecord(
$record
);
return Inertia::render(
'Panels/Admin/Pages/OrderTimeline',
[
'page' =>
$this
->headingMetadata(
$order
),
'events' =>
$order
->events()
->latest()
->get(),
]
);
}
}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
Null Record Key pada Singular Resource
Jika:
resolveRecord(null)digunakan pada Singular Resource, Resource me-resolve record melalui:
resolveSingularRecord()karena route Singular Resource tidak memiliki:
{record}Route Shapes
PanelRouteRegistrar memahami empat Page key built-in:
index
create
view
edit2
3
4
Key lain dianggap custom Resource Page.
Route Table
| Key | Routes |
|---|---|
index | GET / → render, named index |
create | GET create → render (create), POST create → handle (store), POST create/step → validateStep (validateCreateStep) |
view | GET {record} → render, named view |
edit | GET {record}/edit → render (edit), PUT {record}/edit → handle (update), POST {record}/edit/step → validateStep (validateEditStep) |
Custom Resource Page
Page key selain empat built-in menggunakan:
ResourcePage::routePath(
$key
)2
3
Default-nya adalah key tersebut.
Contoh:
'activity' =>
OrderActivity::class2
dengan default route:
activityatau custom:
protected static ?string $routePath =
'{record}/timeline';2
Singular Route
Untuk Singular Resource, segment:
{record}dihapus.
Contohnya ordinary Edit:
/settings/{record}/editmenjadi:
/settings/edituntuk Singular Resource.
Nested Resource Route
Nested Resource group memiliki prefix:
{parentSlug}/{parentRecord}/{slug}dan menggunakan middleware:
ResolveParentRecordContoh:
/projects/{project}/tasksCluster Route
Jika Resource berada di Cluster, cluster slug menjadi prefix path.
Namun route name tetap tidak berubah oleh Cluster.
Route Collision Detection
Jika dua Resource menghasilkan path shape yang sama:
PanelRegistrationException::collidingRoutePath()dilempar saat boot.
Parameter name dihapus saat comparison.
Contohnya:
projects/{record}/tasksdan:
projects/{parentRecord}/taskssama-sama dipandang sebagai:
projects/{}/taskskarena router juga tidak membedakan nama parameter untuk route matching.
Soft Deletes
$softDeletes harus:
- dideklarasikan pada Resource;
- dan model benar-benar menggunakan trait:
SoftDeletesMethod:
usesSoftDeletes()menghasilkan false jika model tidak menggunakan trait tersebut.
Mengapa Perlu Deklarasi dan Verifikasi
Model dapat menggunakan SoftDeletes untuk kebutuhan lain tetapi application mungkin tidak ingin PandaBear menampilkan:
- Restore;
- Force Delete;
- Trashed Filter.
Karena itu trait pada model saja tidak otomatis mengaktifkan UI PandaBear.
Index Tetap Menyembunyikan Trashed Record
Mengaktifkan:
$softDeletes = true;tidak berarti index otomatis menampilkan record yang sudah dihapus.
Yang berubah:
- record Page dapat menjangkau trashed record;
- Restore/ForceDelete menjadi relevan;
TrashedFilterdapat membuka record tersebut.
Index tetap mengikuti SoftDeletingScope sampai user menggunakan filter yang sesuai.
Nested Resource Tidak Memiliki Sidebar Entry
Nested Resource hanya dapat diakses dalam context parent.
Sidebar tidak memiliki parent record.
Karena itu tidak dibuat navigation item.
Resource Tanpa Index Juga Tidak Memiliki Sidebar Entry
Jika Resource hanya memiliki:
create
view
edit2
3
tanpa:
indexframework tidak membuat navigation link.
Navigation tidak boleh menunjuk ke route yang tidak pernah didaftarkan.
integrationSettings() Di-cache per Request
Method:
integrationSettings()dapat dibaca oleh:
- route registrar;
- observer registration;
- Resource Page.
Framework menyimpan resolved result per class per request agar konfigurasi yang sama tidak dihitung berkali-kali.
ManageRelatedRecords Tidak Menghilangkan Inline Relation Table
Jika Relation Manager memiliki dedicated Page:
ManageRelatedRecordsRelation tersebut masih dapat muncul di bawah View/Edit record.
Placement Relation Manager ditentukan Page.
Jika Page ingin hanya sebagian relation muncul inline, override:
relationTables()Ringkasan Arsitektur Resource
Resource
│
├── Model
│
├── TableSchema
│
├── FormSchema
│
├── InfolistSchema
│
├── Pages
│ ├── ListRecords
│ ├── CreateRecord
│ ├── ViewRecord
│ ├── EditRecord
│ └── ManageRelatedRecords
│
├── RelationManagers
│
├── Authorization
│
├── Query Scope
│
├── Global Search
│
├── Widgets
│
├── Integrations
│
├── Tenancy
└── Navigation2
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
Ringkasan Query Boundary
Resource::query()
↓
Nested parent scope
↓
$with eager loading
↓
Tenant scope
↓
Per-panel modifyQueryUsing()
↓
Final query2
3
4
5
6
7
8
9
10
11
Semua operation menggunakan jalur tersebut.
Ringkasan Record Lookup
Request key
↓
Resource::recordQuery()
↓
Resource scopes
↓
SoftDeletingScope removed
↓
find record
↓
found?
├── yes
│ ↓
│ record
│
└── no
↓
4042
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Record di luar tenant/parent/Panel scope juga menghasilkan 404.
Ringkasan Authorization
Resource Ability
↓
canView / canEdit / ...
↓
Resource::authorize()
↓
PolicyGate::allows()
↓
Policy2
3
4
5
6
7
8
9
Dengan strict mode:
Policy tidak ada
atau
method tidak ada
↓
PanelAuthorizationException2
3
4
5
Ringkasan Per-Panel Resource Configuration
UserResource
↓
Admin Panel
├── slug: users
├── label: Users
└── all users
UserResource
↓
CRM Panel
├── slug: people
├── label: People
└── only non-admin2
3
4
5
6
7
8
9
10
11
12
13
Class Resource tetap satu.
Configuration per Panel berbeda.
Ringkasan Create Lifecycle
Request
↓
beforeValidate
↓
Validate
↓
afterValidate
↓
beforeCreate
↓
mutateFormDataBeforeCreate
↓
mutateFormDataBeforeSave
↓
beforeSave
↓
dehydrate
↓
Transaction
├── handleRecordCreation
├── saveRelations
├── afterCreate
└── afterSave
↓
Commit
↓
Redirect2
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
Ringkasan Update Lifecycle
Request
↓
Resolve Record
↓
Authorize Edit
↓
beforeValidate
↓
Validate
↓
afterValidate
↓
mutateFormDataBeforeSave
↓
beforeSave
↓
Transaction
├── handleRecordUpdate
└── afterSave
↓
Commit
↓
Redirect2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Ringkasan ViewRecord
Resolve Record
↓
Authorize
↓
Custom Infolist?
├── yes
│ ↓
│ InfolistSchema
│
└── no
↓
Form-derived entries
↓
PasswordInput skipped2
3
4
5
6
7
8
9
10
11
12
13
14
Ringkasan Nested Resource
ProjectResource
↓
TaskResource
↓
$parentResource =
ProjectResource::class
↓
ResolveParentRecord
↓
ParentRecord
↓
parentRelation()
↓
Task query scoped
ke Project2
3
4
5
6
7
8
9
10
11
12
13
14
15
Ringkasan Singular Resource
Resource
$singular = true
↓
No {record}
↓
resolveSingularRecord()
↓
one model2
3
4
5
6
7
8
Cocok untuk:
Settings
Company Profile
Tenant Configuration
Application Configuration2
3
4
Ringkasan Global Search
Resource
↓
$globalSearchAttributes empty?
├── yes
│ ↓
│ tidak searchable
│
└── no
↓
globalSearchQuery()
↓
Resource::query()
↓
Scoped search
↓
title
details
URL2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Prinsip Utama Resources
Resource PandaBear bukan hanya definisi UI CRUD.
Resource adalah boundary server-side yang menyatukan:
Model
Query Scope
Authorization
Schema
Pages
Navigation
Search
Relations
Tenancy2
3
4
5
6
7
8
9
Prinsip terpentingnya:
Seluruh operation terhadap record harus melewati
Resource::query()dan record lookup milik Resource agar tenant scope, parent scope, per-Panel narrowing, dan authorization context tetap konsisten.
Selain itu:
Resource class dapat digunakan pada beberapa Panel, sementara slug, label, navigation, dan query narrowing dapat berbeda melalui
ResourceConfiguration.
Dan:
Page lifecycle hanya menangani create/edit/view flow. Operation seperti Delete tetap merupakan Action dan menggunakan Action lifecycle serta transaction-nya sendiri.