Product Resource
Contoh CRUD resource lengkap yang dibangun dari nol: migration, model, generator command, form, table, infolist, policy, dan test yang membuktikannya. Gunakan halaman ini ketika Anda sedang menambahkan resource nyata pertama ke aplikasi dan ingin melihat seluruh alurnya dalam satu tempat, bukan berpindah-pindah ke referensi method untuk setiap screen. Semua contoh di sini menggunakan model Product pada Admin Panel dari Contoh Admin Panel. Tiga recipe berikutnya — Relation Manager, Custom Field, dan Import dan Export — dibangun di atas file yang dibuat pada halaman ini.
Contoh minimal yang berfungsi
Dua command dan satu policy:
php artisan make:model Product -m
php artisan make:panel-resource Product --panel=Admin --soft-deletes2
// app/Policies/ProductPolicy.php
namespace App\Policies;
use App\Models\Product;
use App\Models\User;
final class ProductPolicy
{
public function viewAny(User $user): bool
{
return $user->is_admin;
}
public function view(User $user, Product $product): bool
{
return $user->is_admin;
}
public function create(User $user): bool
{
return $user->is_admin;
}
public function update(User $user, Product $product): bool
{
return $user->is_admin;
}
public function delete(User $user, Product $product): bool
{
return $user->is_admin;
}
}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
Tidak ada registration manual. Admin Panel sudah memanggil discoverResources(app_path('Panels/Admin/Resources')), sehingga class baru akan ditemukan ketika Panel berikutnya membangun registry. /admin/products langsung tersedia.
Bagian berikutnya melengkapi setiap file hasil generator.
Generator
php artisan make:panel-resource
{name} # the resource name, singular: Product
--panel= # required
--model= # defaults to App\Models\{name}
--simple # only a list page, for modal-based editing
--no-view # omit the view page
--soft-deletes # trashed filter, restore and force-delete actions
--force # overwrite existing files2
3
4
5
6
7
8
Setiap flag benar-benar mengubah output. --soft-deletes perlu dipahami secara tepat: flag ini menulis protected static bool $softDeletes = true; pada resource, menambahkan TrashedFilter::make('trashed') ke table, serta menambahkan RestoreAction / ForceDeleteAction beserta bulk variant-nya. Ketiganya harus hadir bersama. Restore action tanpa filter yang dapat menampilkan trashed record pada screen adalah tombol yang secara praktik tidak pernah bisa muncul.
--panel menerima nama directory Panel (Admin), bukan ID Panel. Nama resource di-singularize lalu diubah menjadi StudlyCase, sehingga make:panel-resource products dan make:panel-resource Product menghasilkan file yang sama.
File yang dibuat:
app/Panels/Admin/Resources/Products/
├── ProductResource.php
├── Forms/ProductForm.php
├── Tables/ProductsTable.php
└── Pages/
├── ListProducts.php
├── CreateProduct.php
├── ViewProduct.php
└── EditProduct.php2
3
4
5
6
7
8
9
Directory menggunakan bentuk plural, sedangkan class resource menggunakan bentuk singular. Convention inilah yang nantinya dipakai make:panel-relation-manager --resource=Product untuk menemukan directory resource yang benar.
Migration dan model
// database/migrations/xxxx_xx_xx_xxxxxx_create_products_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('products', function (Blueprint $table): void {
$table->id();
$table->foreignId('category_id')->nullable()->constrained()->nullOnDelete();
$table->string('name');
$table->string('slug')->unique();
$table->string('sku')->unique();
$table->text('description')->nullable();
$table->unsignedInteger('price_cents')->default(0);
$table->unsignedInteger('stock')->default(0);
$table->boolean('is_published')->default(false);
$table->string('image')->nullable();
$table->timestamps();
$table->softDeletes();
});
}
};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
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
final class Product extends Model
{
use SoftDeletes;
/**
* `slug` is absent on purpose. It is derived on create and never taken
* from request input, so a form post cannot set one — the same reasoning
* that keeps `is_admin` out of the example user model's `$fillable`.
*
* @var list<string>
*/
protected $fillable = [
'category_id',
'name',
'sku',
'description',
'price_cents',
'stock',
'is_published',
'image',
];
/**
* @return BelongsTo<Category, $this>
*/
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'price_cents' => 'integer',
'stock' => 'integer',
'is_published' => 'boolean',
];
}
}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
Category hanyalah model biasa yang memiliki name dan relation hasMany kembali ke product. Tidak ada bagian model tersebut yang khusus untuk Panel.
Class resource
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Products;
use App\Models\Product;
use App\Panels\Admin\Resources\Products\Forms\ProductForm;
use App\Panels\Admin\Resources\Products\Infolists\ProductInfolist;
use App\Panels\Admin\Resources\Products\Pages\CreateProduct;
use App\Panels\Admin\Resources\Products\Pages\EditProduct;
use App\Panels\Admin\Resources\Products\Pages\ListProducts;
use App\Panels\Admin\Resources\Products\Pages\ViewProduct;
use App\Panels\Admin\Resources\Products\Tables\ProductsTable;
use BackedEnum;
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Forms\FormSchema;
use PandaPanel\Infolists\InfolistSchema;
use PandaPanel\Resources\Resource;
use PandaPanel\Tables\TableSchema;
final class ProductResource extends Resource
{
protected static string $model = Product::class;
protected static ?string $slug = 'products';
protected static ?string $recordTitleAttribute = 'name';
protected static ?string $navigationLabel = 'Products';
protected static ?string $navigationIcon = 'package';
protected static string|BackedEnum|null $navigationGroup = 'Catalog';
protected static int $navigationSort = 10;
/**
* Reaches trashed records, and is what the trashed filter and the restore
* actions are conditioned on.
*/
protected static bool $softDeletes = true;
/**
* The category column reads a relation on every row. Without this it
* would lazy load once per row, which `Model::shouldBeStrict()` turns
* into a loud failure outside production and a slow page inside it.
*
* @var list<string>
*/
protected static array $with = ['category'];
/** @var list<string> */
protected static array $globalSearchAttributes = ['name', 'sku'];
/**
* @return array<string, string>
*/
public static function globalSearchResultDetails(Model $record): array
{
return [
'SKU' => (string) $record->getAttribute('sku'),
'Category' => (string) ($record->getAttribute('category')?->name ?? 'Uncategorised'),
];
}
public static function table(TableSchema $table): TableSchema
{
return ProductsTable::configure($table);
}
public static function form(FormSchema $schema): FormSchema
{
return ProductForm::configure($schema);
}
public static function infolist(InfolistSchema $schema): InfolistSchema
{
return ProductInfolist::configure($schema);
}
/**
* @return array<string, class-string>
*/
public static function pages(): array
{
return [
'index' => ListProducts::class,
'create' => CreateProduct::class,
'view' => ViewProduct::class,
'edit' => EditProduct::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
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
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
Semua deklarasi yang dimiliki resource
Semua properti berikut adalah protected static dan memiliki reader dengan nama yang sama. Jika suatu rule bergantung pada kondisi yang tidak dapat dinyatakan sebagai konstanta/properti, override method pembacanya daripada mencoba mengubah properti secara dinamis.
| Property | Type | Default | Menentukan |
|---|---|---|---|
$model | string | wajib | Eloquent model. Jika tidak ada, framework melempar PanelSchemaException::missingModel() sambil menyebut class resource |
$slug | ?string | plural kebab dari basename class model | Segmen URL dan key yang digunakan action payload |
$label | ?string | Str::headline() dari basename class model | "Product" pada heading dan confirmation |
$pluralLabel | ?string | bentuk plural dari label | "Products" |
$recordTitleAttribute | ?string | null, lalu recordTitle() membaca name | Attribute untuk menamai record; fallback ke key jika value tidak scalar |
$navigationLabel | ?string | plural label | Label sidebar |
$navigationIcon | ?string | null | Icon registry key |
$activeNavigationIcon | ?string | null | Icon berbeda ketika section aktif |
$navigationGroup | string|BackedEnum|null | null | Group tempat navigation item berada |
$navigationSort | int | 0 | Urutan di dalam group |
$shouldRegisterNavigation | bool | true | false mempertahankan route tetapi menyembunyikan sidebar item |
$cluster | ?string | null | Cluster tempat resource berada |
$subNavigationPosition | ?SubNavigationPosition | null | Posisi sub-navigation milik record |
$globalSearchAttributes | list<string> | [] | Kosong berarti resource tidak ikut global search |
$globalSearchLimit | int | 5 | Jumlah result dari resource ini |
$globalSearchSort | int | 0 | Urutan group result resource ini |
$with | list<string> | [] | Relation yang eager loaded oleh query() |
$softDeletes | bool | false | Memungkinkan query menjangkau trashed record |
$singular | bool | false | Satu record tanpa index, cocok untuk settings-style resource |
$parentResource | ?string | null | Menjadikan resource sebagai nested resource |
$parentRelationship | ?string | camel dari slug | Relation pada parent |
$tenantRelationship | ?string | null | Relation yang mengarah ke tenant — lihat Tenant Panel |
Dua method wajib diimplementasikan: table() dan form(). pages() juga abstract. infolist() secara default mengembalikan schema tanpa perubahan, sehingga view page akan fallback ke read-only representation dari form.
abstract public static function table(TableSchema $table): TableSchema;
abstract public static function form(FormSchema $schema): FormSchema;
abstract public static function pages(): array;
public static function infolist(InfolistSchema $schema): InfolistSchema;
public static function relationManagers(): array; // list<class-string<RelationManager>>2
3
4
5
6
Form
Form disimpan pada class terpisah, sesuai output generator dan pola pada example. Resource yang menaruh seluruh form secara inline akan cepat kehilangan keterbacaan ketika section mulai bertambah.
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Products\Forms;
use App\Models\Category;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
use PandaPanel\Forms\Components\FileUpload;
use PandaPanel\Forms\Components\NumberInput;
use PandaPanel\Forms\Components\Select;
use PandaPanel\Forms\Components\Textarea;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\Components\Toggle;
use PandaPanel\Forms\FormSchema;
use PandaPanel\Forms\Layouts\Section;
final class ProductForm
{
public static function configure(FormSchema $schema): FormSchema
{
return $schema
->columns(2)
->schema([
Section::make('Details')
->columns(2)
->schema([
TextInput::make('name')
->required()
->maxLength(255)
->placeholder('Mechanical keyboard'),
// Unique on create, unique-except-self on edit.
// Without the ignore, saving a product without
// touching its SKU would fail against itself.
TextInput::make('sku')
->label('SKU')
->required()
->maxLength(64)
->rulesUsing(static fn (?Model $record): array => [
$record === null
? Rule::unique('products', 'sku')
: Rule::unique('products', 'sku')->ignore($record->getKey()),
]),
Select::make('category_id')
->label('Category')
// The options come from the relation, resolved on
// the server and bounded. The browser receives
// value/label pairs and never the query.
->relationship('category', 'name')
->searchable()
->optionLimit(100),
Textarea::make('description')
->rows(5)
->maxLength(2000)
->columnSpan(2),
]),
Section::make('Pricing and stock')
->description('Prices are stored in cents, so nothing is ever rounded twice.')
->columns(2)
->schema([
NumberInput::make('price_cents')
->label('Price (cents)')
->integer()
->min(0)
->step(1)
->required(),
NumberInput::make('stock')
->integer()
->min(0)
->default(0)
->required(),
Toggle::make('is_published')
->label('Published')
->helperText('Unpublished products are hidden from the storefront.')
->columnSpan(2),
]),
Section::make('Media')
->schema([
FileUpload::make('image')
->image()
->disk('public')
->directory('products')
->maxSize(2048)
->columnSpanFull(),
]),
]);
}
}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
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
Ada tiga hal penting pada schema tersebut.
rulesUsing(Closure $callback) menerima record yang sedang diedit, atau null saat create. Dengan demikian satu field dapat mendefinisikan rule create dan edit tanpa membuat seluruh form bercabang berdasarkan $schema->getPage().
Select::relationship(string $relation, string $titleAttribute) membaca option melalui relation lalu mendehydrate value menjadi foreign key. searchable() memindahkan pencarian ke options endpoint milik Panel dan jumlah result tetap dibatasi oleh optionLimit() — default-nya 50.
FileUpload tidak pernah memasukkan binary file ke form payload. Browser melakukan POST ke upload endpoint Panel, endpoint menyimpan file lalu mengembalikan path, dan form submit hanya mengirim path tersebut seperti string biasa. Disk dan directory yang dideklarasikan field ditegakkan pada upload endpoint dan diperiksa lagi saat form submit. Path yang berada di luar directory akan ditolak, bukan ditempelkan ke record. Lihat File Uploads.
Table
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Products\Tables;
use App\Models\Category;
use App\Panels\Admin\Resources\Products\ProductResource;
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Actions\CreateAction;
use PandaPanel\Actions\DeleteAction;
use PandaPanel\Actions\DeleteBulkAction;
use PandaPanel\Actions\EditAction;
use PandaPanel\Actions\ForceDeleteAction;
use PandaPanel\Actions\RestoreAction;
use PandaPanel\Actions\ViewAction;
use PandaPanel\Tables\Columns\BadgeColumn;
use PandaPanel\Tables\Columns\DateTimeColumn;
use PandaPanel\Tables\Columns\ImageColumn;
use PandaPanel\Tables\Columns\NumberColumn;
use PandaPanel\Tables\Columns\TextColumn;
use PandaPanel\Tables\Columns\ToggleColumn;
use PandaPanel\Tables\Enums\Alignment;
use PandaPanel\Tables\Enums\BadgeColor;
use PandaPanel\Tables\Enums\SortDirection;
use PandaPanel\Tables\Filters\SelectFilter;
use PandaPanel\Tables\Filters\TernaryFilter;
use PandaPanel\Tables\Filters\TrashedFilter;
use PandaPanel\Tables\Group;
use PandaPanel\Tables\Summaries\Average;
use PandaPanel\Tables\Summaries\Count;
use PandaPanel\Tables\Summaries\Sum;
use PandaPanel\Tables\TableSchema;
final class ProductsTable
{
public static function configure(TableSchema $table): TableSchema
{
return $table
->columns([
ImageColumn::make('image')
->label('')
->size(40)
->toggleable(false),
TextColumn::make('name')
->searchable(individually: true)
->sortable()
->toggleable(false),
TextColumn::make('sku')
->label('SKU')
->searchable()
->sortable(),
// A relation read as a column. The value is reached with dot
// notation and the search runs as a `whereHas`, so nothing
// has to be denormalised onto `products`.
TextColumn::make('category.name')
->label('Category')
->searchable()
->placeholder('Uncategorised'),
NumberColumn::make('price_cents')
->label('Price')
->prefix('$')
->decimals(2)
->formatUsing(static fn (mixed $value): float => (int) $value / 100)
->alignment(Alignment::End)
->sortable()
->summarize([Average::make()->label('Average')]),
NumberColumn::make('stock')
->alignment(Alignment::End)
->sortable()
->summarize([Sum::make()->label('Units'), Count::make()->label('Products')]),
BadgeColumn::make('stock_state')
->label('Availability')
->formatUsing(static fn (mixed $value, Model $record): string => match (true) {
(int) $record->getAttribute('stock') === 0 => 'out',
(int) $record->getAttribute('stock') < 10 => 'low',
default => 'ok',
})
->labels(['ok' => 'In stock', 'low' => 'Low', 'out' => 'Out of stock'])
->colors([
'ok' => BadgeColor::Success,
'low' => BadgeColor::Warning,
'out' => BadgeColor::Danger,
]),
// Editable in place. The endpoint re-checks the `update`
// ability for this record before writing.
ToggleColumn::make('is_published')
->label('Published')
->alignment(Alignment::Center),
DateTimeColumn::make('created_at')
->label('Added')
->sortable()
->visible(false),
])
->filters([
TernaryFilter::make('is_published')
->label('Visibility')
->labels('Published', 'Draft', 'Any'),
SelectFilter::make('category_id')
->label('Category')
->options(self::categoryOptions())
->placeholder('Any category'),
TrashedFilter::make('trashed'),
])
->groups([
Group::make('category_id')
->label('Category')
->titleUsing(static fn (Model $record): string => (string) ($record
->getAttribute('category')?->name ?? 'Uncategorised')),
])
->headerActions([
CreateAction::make(ProductResource::class)->label('New product'),
])
->recordActions([
ViewAction::make(ProductResource::class),
EditAction::make(ProductResource::class),
RestoreAction::make(ProductResource::class),
ForceDeleteAction::make(ProductResource::class),
DeleteAction::make(ProductResource::class),
])
->bulkActions([
DeleteBulkAction::make(ProductResource::class),
])
->defaultSort('created_at', SortDirection::Descending)
->searchPlaceholder('Search by name, SKU, or category...')
->persistFiltersInSession()
->persistColumnsInSession()
->emptyState(
heading: 'No products yet',
description: 'Add the first one, or adjust the filters.',
icon: 'package',
);
}
/**
* @return array<string, string>
*/
private static function categoryOptions(): array
{
return Category::query()
->orderBy('name')
->pluck('name', 'id')
->map(strval(...))
->all();
}
}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
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
SelectFilter::options(array $options) menerima plain array yang di-key oleh value yang akan ditulis filter ke query. Membangun array dari query di configure() aman karena schema dibangun sekali per request yang merender table, bukan saat Panel provider dikonfigurasi.
Key option yang dideklarasikan berfungsi sebagai whitelist. sanitize() menolak value yang tidak terdapat di daftar. Request manual seperti filters[category_id]=99999 tidak akan mendapat akses bebas ke query builder.
RestoreAction dan ForceDeleteAction disembunyikan pada record yang belum trashed. Karena itu memasukkannya selalu ke daftar record action adalah pola yang benar — action baru muncul ketika trashed filter benar-benar menempatkan deleted record pada screen.
Infolist
View page merender Resource::infolist(). Jika tidak diimplementasikan, page akan menampilkan read-only representation dari form. Default ini cukup masuk akal untuk form sederhana, tetapi menjadi pilihan yang buruk ketika form berisi field sensitif seperti password atau upload.
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Products\Infolists;
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Infolists\Components\BooleanEntry;
use PandaPanel\Infolists\Components\DateTimeEntry;
use PandaPanel\Infolists\Components\ImageEntry;
use PandaPanel\Infolists\Components\TextEntry;
use PandaPanel\Infolists\InfolistSchema;
use PandaPanel\Infolists\Layouts\Section;
final class ProductInfolist
{
public static function configure(InfolistSchema $schema): InfolistSchema
{
return $schema
->columns(2)
->schema([
Section::make('Product')
->columns(2)
->schema([
ImageEntry::make('image')->label('')->columnSpan(2),
TextEntry::make('name'),
TextEntry::make('sku')->label('SKU'),
TextEntry::make('category.name')
->label('Category')
->placeholder('Uncategorised'),
TextEntry::make('price_cents')
->label('Price')
->formatUsing(static fn (mixed $value): string
=> '$'.number_format((int) $value / 100, 2)),
BooleanEntry::make('is_published')
->label('Visibility')
->labels('Published', 'Draft'),
TextEntry::make('description')
->columnSpan(2)
->placeholder('No description.'),
]),
Section::make('History')
->columns(2)
->schema([
DateTimeEntry::make('created_at')->label('Added'),
DateTimeEntry::make('updated_at')->label('Last changed')->since(),
]),
]);
}
}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
Page
Generator membuat empat class yang hampir satu baris. Class terpisah tetap dibuat agar setiap page nantinya dapat menerima behavior tambahan tanpa mengubah bentuk resource.
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Products\Pages;
use App\Panels\Admin\Resources\Products\ProductResource;
use PandaPanel\Resources\Pages\ListRecords;
final class ListProducts extends ListRecords
{
protected static string $resource = ProductResource::class;
}2
3
4
5
6
7
8
9
10
11
12
13
CreateProduct, ViewProduct, dan EditProduct menggunakan pola yang sama dan masing-masing meng-extend CreateRecord, ViewRecord, dan EditRecord.
Membuat slug saat create
slug sengaja tidak menjadi form field, sehingga value tersebut harus diisi oleh server. Lifecycle hook pada create page adalah tempat yang tepat:
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Products\Pages;
use App\Panels\Admin\Resources\Products\ProductResource;
use Illuminate\Support\Str;
use PandaPanel\Resources\Pages\CreateRecord;
final class CreateProduct extends CreateRecord
{
protected static string $resource = ProductResource::class;
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['slug'] = Str::slug((string) ($data['name'] ?? '')).'-'.Str::lower(Str::random(6));
return $data;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Urutan lifecycle hook yang dapat di-override resource page:
Rendering: beforeFill → mutateFormDataBeforeFill → afterFill
Create: beforeValidate → validate → afterValidate → beforeCreate
→ mutateFormDataBeforeCreate → mutateFormDataBeforeSave → beforeSave
→ handleRecordCreation → afterCreate → afterSave
Update: beforeValidate → validate → afterValidate
→ mutateFormDataBeforeSave → beforeSave → handleRecordUpdate → afterSave2
3
4
5
6
Hook mutate* menerima data lalu harus mengembalikannya kembali. Hook lainnya tidak mengembalikan value dan digunakan untuk side effect atau memanggil halt(), yang menghentikan lifecycle sebelum ada data yang ditulis.
Delete tidak memiliki page hook pada jalur ini karena deletion berjalan melalui action endpoint tanpa page instance. Gunakan Action::before() dan Action::after() untuk behavior sebelum/sesudah delete. Referensi lengkap: Lifecycle Hooks.
Policy
Resource tidak mengimplementasikan authorization logic sendiri. Semua can*() pada Resource diterjemahkan menjadi ability pada Laravel policy melalui Gate:
| Method Resource | Ability | Subject |
|---|---|---|
canViewAny() | viewAny | class model |
canView($record) | view | record |
canCreate() | create | class model |
canEdit($record) | update | record |
canDelete($record) | delete | record |
canDeleteAny() | deleteAny | class model |
canRestore($record) | restore | record |
canRestoreAny() | restoreAny | class model |
canForceDelete($record) | forceDelete | record |
canForceDeleteAny() | forceDeleteAny | class model |
Resource yang mendukung soft delete membutuhkan empat ability terakhir di samping enam ability pertama:
public function restore(User $user, Product $product): bool
{
return $user->is_admin;
}
public function restoreAny(User $user): bool
{
return $user->is_admin;
}
public function forceDelete(User $user, Product $product): bool
{
return $user->is_admin;
}
public function forceDeleteAny(User $user): bool
{
return $user->is_admin;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Ability *Any digunakan bulk action sebelum action memiliki record tertentu untuk diperiksa. Setelah itu setiap selected record tetap di-authorize secara individual sebelum ada perubahan data.
Aktifkan strictAuthorization() saat mengembangkan resource ini. Tanpanya, restoreAny yang hilang terlihat sama persis dengan policy yang memang menolak:
$panel->strictAuthorization(); // off by defaultKetika aktif, model tanpa policy terdaftar — atau policy yang tidak memiliki ability yang sedang ditanyakan — melempar PandaPanel\Exceptions\PanelAuthorizationException sambil menyebut model dan ability. Lihat Security.
Mempersempit record yang dapat dilihat resource
Resource::query() adalah funnel tunggal. List, record lookup, seluruh action endpoint, global search, dan export melewati query ini. Record yang dikeluarkan query menjadi 404 di semua jalur, bukan hanya row yang hilang dari satu table.
use Illuminate\Database\Eloquent\Builder;
public static function query(): Builder
{
// parent::query() applies $with, the tenant scope, and the panel's own
// per-panel narrowing. An override that skips it drops all three.
return parent::query()->where('is_archived', false);
}2
3
4
5
6
7
8
Route yang dihasilkan
| URL | Route name | Class |
|---|---|---|
/admin/products | panel.admin.resources.products.index | ListProducts |
/admin/products/create | .create, .store | CreateProduct |
/admin/products/{record} | .view | ViewProduct |
/admin/products/{record}/edit | .edit, .update | EditProduct |
use App\Panels\Admin\Resources\Products\ProductResource;
ProductResource::url(); // /admin/products
ProductResource::url('edit', $product); // /admin/products/12/edit
ProductResource::routeName('index'); // panel.admin.resources.products.index2
3
4
5
url() memastikan resource memang terdaftar pada Panel yang diminta. Jika resource tidak terdaftar, method melempar exception daripada membuat URL yang baru akan 404 ketika dibuka.
Test
<?php
declare(strict_types=1);
use App\Models\Category;
use App\Models\Product;
use App\Models\User;
use App\Panels\Admin\Resources\Products\ProductResource;
use PandaPanel\Core\PanelManager;
beforeEach(function (): void {
app(PanelManager::class)->setCurrentPanel(panel('admin'));
$this->admin = User::factory()->admin()->create();
$this->category = Category::query()->create(['name' => 'Peripherals']);
$this->actingAs($this->admin);
});
it('lists products for an administrator', function (): void {
$product = Product::factory()->create(['name' => 'Mechanical keyboard']);
$this->get('/admin/products')->assertOk();
panelTable(ProductResource::class)
->assertCanSeeRecord($product)
->assertCount(1);
});
it('keeps a non-administrator out', function (): void {
$this->actingAs(User::factory()->create())
->get('/admin/products')
->assertForbidden();
});
it('requires the fields the schema declared required', function (): void {
panelForm(ProductResource::class)
->assertHasField('sku')
->assertFieldIsRequired('name')
->assertFieldIsRequired('sku');
});
it('creates a product and derives its slug', function (): void {
$this->post('/admin/products/create', [
'name' => 'Mechanical keyboard',
'sku' => 'KB-001',
'category_id' => $this->category->getKey(),
'price_cents' => 12900,
'stock' => 4,
'is_published' => true,
])->assertRedirect();
$product = Product::query()->firstWhere('sku', 'KB-001');
expect($product)->not->toBeNull()
->and($product?->slug)->toStartWith('mechanical-keyboard-');
});
it('writes only what the form declared', function (): void {
$this->post('/admin/products/create', [
'name' => 'Mouse',
'sku' => 'MS-001',
'price_cents' => 4900,
'stock' => 1,
// Not a field on the form. A key the schema never declared is
// discarded rather than written.
'slug' => 'chosen-by-the-request',
])->assertRedirect();
expect(Product::query()->firstWhere('sku', 'MS-001')?->slug)
->not->toBe('chosen-by-the-request');
});
it('restores a trashed product through the action endpoint', function (): void {
$product = Product::factory()->create();
$product->delete();
$this->post('/admin/actions/record', [
'resource' => 'products',
'action' => 'restore',
'record' => $product->getKey(),
])->assertRedirect();
expect($product->fresh()?->trashed())->toBeFalse();
});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
77
78
79
80
81
82
83
84
85
panelTable() dan panelForm() adalah free function yang disediakan package dan di-autoload secara otomatis, sehingga tidak membutuhkan import maupun custom base test class. Keduanya menggunakan TableSchema dan FormSchema yang nyata; helper ini hanya memberikan cara yang lebih mudah untuk menanyakan schema, bukan implementasi kedua yang berpotensi berbeda. Lihat Testing Helpers.
php artisan test --compact --filter=ProductHal yang perlu diperhatikan
- Ketika manifest sudah di-cache, discovery tidak berjalan. Setelah menambahkan resource pada production, jalankan kembali
php artisan panel:cache.optimizedanoptimize:clearsudah mencakup cache Panel. --soft-deletesadalah deklarasi, bukan autodetection. Flag menulis$softDeletes = truepada resource. Model yang memakai traitSoftDeletestetapi resource tidak mendeklarasikannya tetap menggunakan default scope sehingga trashed record tidak terlihat — dan pada beberapa use case memang itu yang diinginkan.- Icon yang tidak ada pada registry tidak dirender dan tidak melempar error.
php artisan panel:iconsmembangun ulang registry dari source dan gagal berdasarkan nama jika Lucide icon tidak tersedia. $withbukan optimasi opsional ketika column membaca relation.category.nametanpaprotected static array $with = ['category']menghasilkan satu query per row.- Override
query()yang lupa memanggilparent::query()sekaligus membuang eager load, tenant scope, dan per-panelmodifyQueryUsing(), tanpa indikasi visual apa pun pada screen. Resource::$slugdan kolomslugpada model tidak berhubungan.Resource::$slugadalah segmen URL;Product::$slugadalah attribute model. Keduanya hanya memiliki nama yang sama.- Record yang dikeluarkan
query()menjadi 404, bukan 403. Ini disengaja: dari perspektif resource, record tersebut tidak ada. Mengembalikan 403 justru akan mengonfirmasi keberadaan record.
Lihat juga
- Contoh Admin Panel — Panel yang menemukan resource ini
- User Resource — example bawaan yang lebih kompleks
- Relation Manager — variants dan tags milik product
- Custom Field — rating field pada form product
- Import dan Export — product dalam spreadsheet
- Creating Resources
- Resource API Reference
- Lifecycle Hooks
- Soft Deletes
- Global Search
- Tables Overview
- Forms Overview
- Infolists Overview
- make:panel-resource