User Resource
Resource user yang disertakan di examples/, dibahas file demi file. Ini adalah worked example terbesar di repository: form yang berbeda behavior antara create dan edit, sebelas column termasuk editable column dan Vue column custom, enam jenis filter, infolist dengan tabs, exporter, importer, serta policy yang mengizinkan member mengedit tepat satu account miliknya sendiri. Gunakan halaman ini ketika Anda membutuhkan Resource nyata untuk disalin, bukan daftar fitur. Semua file yang disebutkan tersedia di repository dan test suite framework dijalankan terhadapnya.
Contoh minimal yang berfungsi
Jika membangunnya dari awal sekarang:
php artisan make:panel-resource User --panel=Admin --model=App\\Models\\UserCommand tersebut membuat Resource, empat page, table, dan form. Admin Panel sudah memanggil discoverResources(app_path('Panels/Admin/Resources')), sehingga /admin/users tersedia segera setelah App\Policies\UserPolicy mengizinkannya. Bagian berikutnya menunjukkan apa yang kemudian diisi oleh example.
File
examples/app/Panels/Admin/Resources/Users/
├── UserResource.php
├── Forms/UserForm.php
├── Tables/UsersTable.php
├── Infolists/UserInfolist.php
├── Exports/UserExporter.php
├── Imports/UserImporter.php
└── Pages/
├── ListUsers.php
├── CreateUser.php
├── ViewUser.php
└── EditUser.php
examples/app/Policies/UserPolicy.php
examples/resources/js/pages/Panels/Admin/Columns/AccountAge.vue2
3
4
5
6
7
8
9
10
11
12
13
14
15
Resource
examples/app/Panels/Admin/Resources/Users/UserResource.php:
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Users;
use App\Models\User;
use App\Panels\Admin\Resources\Users\Forms\UserForm;
use App\Panels\Admin\Resources\Users\Infolists\UserInfolist;
use App\Panels\Admin\Resources\Users\Pages\CreateUser;
use App\Panels\Admin\Resources\Users\Pages\EditUser;
use App\Panels\Admin\Resources\Users\Pages\ListUsers;
use App\Panels\Admin\Resources\Users\Pages\ViewUser;
use App\Panels\Admin\Resources\Users\Tables\UsersTable;
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 UserResource extends Resource
{
protected static string $model = User::class;
protected static ?string $slug = 'users';
protected static ?string $navigationLabel = 'Users';
protected static ?string $navigationIcon = 'users';
protected static string|BackedEnum|null $navigationGroup = 'User Management';
protected static int $navigationSort = 10;
/** @var list<string> */
protected static array $globalSearchAttributes = ['name', 'email'];
/** @var list<string> */
protected static array $with = ['passkeys'];
/**
* @return array<string, string>
*/
public static function globalSearchResultDetails(Model $record): array
{
return [
'Email' => (string) $record->getAttribute('email'),
'Role' => $record instanceof User && $record->is_admin ? 'Administrator' : 'Member',
];
}
public static function table(TableSchema $table): TableSchema
{
return UsersTable::configure($table);
}
public static function form(FormSchema $schema): FormSchema
{
return UserForm::configure($schema);
}
public static function infolist(InfolistSchema $schema): InfolistSchema
{
return UserInfolist::configure($schema);
}
/**
* @return array<string, class-string>
*/
public static function pages(): array
{
return [
'index' => ListUsers::class,
'create' => CreateUser::class,
'view' => ViewUser::class,
'edit' => EditUser::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
Class Resource tidak menyimpan schema inline. Hanya ada tiga delegation dan satu page map. Pola ini menjaga Resource tetap mudah dibaca ketika table-nya sudah berkembang menjadi ratusan baris.
$with = ['passkeys'] bukan dekorasi. Satu column merender nama passkey dan column lain menghitungnya; tanpa eager load akan terjadi query per row. Model::shouldBeStrict() — yang aktif di luar production pada examples — mengubah kondisi ini menjadi failure yang jelas alih-alih page yang diam-diam lambat.
$globalSearchAttributes adalah seluruh opt-in global search. Array kosong berarti Resource tidak searchable. Mendeklarasikan attribute mengaktifkannya, sedangkan globalSearchResultDetails() mengubah result yang hanya berupa nama menjadi result yang dapat dibedakan manusia.
Form
Forms/UserForm.php. Bagian menariknya adalah satu class menghasilkan dua form dengan behavior berbeda.
public static function configure(FormSchema $schema): FormSchema
{
$isCreate = $schema->getPage() === 'create';
return $schema
->columns(2)
->schema([
Section::make('Personal Information')
->columns(2)
->schema([
TextInput::make('name')
->required()
->maxLength(255)
->placeholder('Ada Lovelace'),
TextInput::make('email')
->label('Email address')
->email()
->required()
->maxLength(255)
->rulesUsing(static fn (?Model $record): array => [
$record === null
? Rule::unique('users', 'email')
: Rule::unique('users', 'email')->ignore($record->getKey()),
]),
// …
]),
// …
]);
}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
FormSchema::getPage() mengembalikan create, edit, atau view — nama page yang sama dengan yang dibandingkan oleh hiddenOn() dan visibleOn(). Branching di satu schema menjaga definisi "seperti apa sebuah User" tetap berada di satu tempat daripada membuat dua form class yang mudah drift.
Password wajib sekali, lalu opsional selamanya
PasswordInput::make('password')
->confirmed()
->rules(['min:8'])
->columnSpan(2)
->when(
$isCreate,
static fn (PasswordInput $field): PasswordInput => $field->required(),
static fn (PasswordInput $field): PasswordInput => $field->optionalWhenFilled(),
);2
3
4
5
6
7
8
9
when() berasal dari Conditionable milik Laravel yang digunakan FormComponent, sehingga tersedia pada semua field dan layout.
public function optionalWhenFilled(): self
{
return $this
->required(false)
->dehydrateWhen(static fn (mixed $value): bool => is_string($value) && $value !== '');
}2
3
4
5
6
Inilah alasan dehydrateWhen() tersedia. Pada edit, field password harus opsional, tetap divalidasi ketika user mengisinya, dan dibuang sama sekali ketika dibiarkan kosong. Tanpa behavior ini hash password yang sudah tersimpan dapat tertimpa string kosong.
confirmed() menambahkan Laravel rule confirmed, yang mengharapkan field password_confirmation berada di sampingnya.
Toggle di atas timestamp
Users table tidak memiliki column status, sehingga state yang benar-benar ditoggle adalah timestamp verifikasi. Control boolean di atas nullable datetime membutuhkan tiga state hook sekaligus:
Toggle::make('verified')
->label('Email verified')
->helperText('Marks the address as verified without sending an email.')
->columnSpan(2)
->formatUsing(static fn (mixed $value, ?Model $record): bool => $record instanceof User
&& $record->email_verified_at !== null)
->dehydrateTo('email_verified_at')
->mutateUsing(static function (mixed $value, ?Model $record): mixed {
if ($value !== true) {
return null;
}
return $record instanceof User && $record->email_verified_at !== null
? $record->email_verified_at
: Date::now();
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| Method | Signature | Fungsi pada contoh ini |
|---|---|---|
formatUsing() | formatUsing(Closure $callback): static | Mengubah stored timestamp menjadi boolean yang ditampilkan control |
dehydrateTo() | dehydrateTo(string $attribute): static | Menulis ke email_verified_at walaupun nama field adalah verified |
mutateUsing() | mutateUsing(Closure $callback): static | Mengubah boolean kembali menjadi timestamp sambil mempertahankan timestamp lama |
Mempertahankan timestamp lama penting. Tanpanya, setiap save pada account yang sudah verified akan memindahkan tanggal verifikasi menjadi "sekarang".
Table
Tables/UsersTable.php adalah file terpanjang di examples. Lebih mudah dipahami jika dibaca per bagian.
Schema
return $table
->columns(self::columns())
->filters(self::filters())
->groups([/* … */])
->headerActions(self::headerActions())
->toolbarActions([/* … */])
->recordActions([/* … */])
->recordActionsPosition(RecordActionsPosition::AfterColumns)
->recordActionsLabel('Manage')
->bulkActions([/* … */])
->defaultSort('created_at', SortDirection::Descending)
->defaultSortOptionLabel('newest first')
->searchPlaceholder('Search by name, email, or passkey...')
->searchDebounce(400)
->splitSearchTerms()
->persistSearchInSession()
->persistSortInSession()
->persistFiltersInSession()
->persistColumnsInSession()
->deferFilters()
->filtersApplyLabel('Apply filters')
->filtersResetLabel('Clear all')
->reorderableColumns()
->columnManagerInModal()
->columnManagerTrigger('Columns', 'settings')
->emptyStateActions(self::headerActions())
->emptyState(
heading: 'No users match this view',
description: 'Adjust the search or filters, or add a new user.',
icon: 'users',
);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
Empat konfigurasi di atas merupakan keputusan eksplisit, bukan sekadar default.
splitSearchTerms() mempersempit query untuk setiap kata secara terpisah. Search "ada admin" dapat menemukan administrator bernama Ada, bukan mencari exact string "ada admin".
deferFilters() menunggu user menekan apply. Table dapat membawa query builder dengan beberapa rule; menjalankan ulang query pada setiap keystroke ketika condition masih setengah dibuat hanya membuang pekerjaan.
emptyStateActions(self::headerActions()) menggunakan kembali Action header. Empty table justru merupakan tempat tombol "add user" paling dibutuhkan dan paling mudah hilang jika hanya diletakkan di header.
persist*InSession() mengingat view per user, di-key berdasarkan Panel dan Resource. URL tetap memiliki prioritas ketika secara eksplisit menyatakan state lain.
Columns
Ada sebelas column, masing-masing menunjukkan capability berbeda.
// A picture, never toggled off.
ImageColumn::make('avatar')
->label('')
->circular()
->width('3.5rem')
->toggleable(false),
// Editable in place. `individually: true` adds a per-column search box.
TextInputColumn::make('name')
->maxLength(255)
->rules(['required'])
->searchable(individually: true)
->sortable()
->width('14rem')
->toggleable(false)
->tooltip(static fn (Model $record): string => 'Joined '
.self::asDate($record->getAttribute('created_at'))?->diffForHumans()),
// A link out of the table, plus a data attribute the frontend can hook.
TextColumn::make('email')
->searchable(individually: true)
->sortable()
->headerTooltip('The address sign-in and notifications use')
->url(static fn (Model $record): string => 'mailto:'.$record->getAttribute('email'))
->extraAttributes(static fn (Model $record): array => [
'data-user' => (string) $record->getKey(),
]),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
Badge berikut juga menjadi button. Menekannya langsung melakukan verifikasi account, yaitu operasi yang memang ingin dilakukan administrator dari column tersebut:
BadgeColumn::make('email_verified_at')
->label('Status')
->formatUsing(static fn (mixed $value): string => $value === null ? 'unverified' : 'verified')
->labels(['verified' => 'Verified', 'unverified' => 'Unverified'])
->colors([
'verified' => BadgeColor::Success,
'unverified' => BadgeColor::Warning,
])
->sortable()
->action(
Action::make('verifyOne')
->label('Mark as verified')
->successMessage('Account marked as verified.')
->visible(static fn (?Model $record): bool => $record !== null
&& $record->getAttribute('email_verified_at') === null)
->authorize(static fn (?Model $record): bool => self::actorIsAdmin())
->action(static function (Model $record): void {
$record->forceFill(['email_verified_at' => now()])->save();
}),
),2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
visible() menentukan apakah button digambar. authorize() menentukan apakah Action boleh dijalankan dan diperiksa kembali oleh endpoint ketika request benar-benar masuk. Yang pertama adalah convenience UI; yang kedua adalah control keamanan.
Toggle berikut memiliki guard yang tidak dapat diekspresikan policy update umum:
ToggleColumn::make('is_admin')
->label('Admin')
->alignment(Alignment::Center)
->width('5rem')
->disabledUsing(static function (Model $record): bool {
$actor = auth()->user();
return ! self::actorIsAdmin()
|| ($actor instanceof User && $actor->is($record));
}),2
3
4
5
6
7
8
9
10
Policy mengizinkan user mengedit record miliknya sendiri. Itu benar untuk display name tetapi sangat berbahaya untuk privilege flag. disabledUsing() adalah rule tambahan yang dibutuhkan. /admin/actions/cell memeriksa kondisi yang sama di server, dan tests/Feature/Panel/Negative/PrivilegeEscalationTest.php memastikan hand-written POST tidak dapat melewatinya.
Aggregate relation dengan summary:
NumberColumn::make('passkeys_count')
->label('Passkeys')
->counts('passkeys')
->sortable()
->alignment(Alignment::End)
->width('6rem')
->summarize([Sum::make()->label('Total'), Count::make()->label('Accounts')]),2
3
4
5
6
7
counts() menambahkan withCount, sehingga alias dihasilkan database tanpa hydrate relation. Summary dihitung terhadap seluruh filtered query, bukan hanya page yang sedang terlihat. UsersTableAppliedTest membuktikannya pada 31 account dengan page size sepuluh.
Custom sort yang tidak dapat diekspresikan hanya dengan nama column:
TextColumn::make('attention')
->label('Needs attention')
->visible(false)
->alignment(Alignment::Center)
->formatUsing(static fn (mixed $value, Model $record): string => $record->getAttribute('email_verified_at') === null
? 'Unverified'
: '—')
->sortUsing(static function (Builder $query, SortDirection $direction): void {
$query
->orderByRaw('email_verified_at is null '.($direction === SortDirection::Ascending ? 'asc' : 'desc'))
->orderBy('created_at');
}),2
3
4
5
6
7
8
9
10
11
12
sortUsing() menerima direction yang sudah divalidasi, sehingga value yang mencapai builder hanya asc atau desc.
Dan sebuah column yang dirender Vue component milik aplikasi:
CustomColumn::make('accountAge')
->label('Account age')
->component('Panels/Admin/Columns/AccountAge')
->width('9rem')
->state(static function (Model $record): array {
$created = self::asDate($record->getAttribute('created_at'));
return [
'days' => $created === null ? 0 : (int) $created->diffInDays(now()),
'label' => $created?->diffForHumans() ?? 'Unknown',
];
}),2
3
4
5
6
7
8
9
10
11
12
Nama component merupakan build-time registry key, bukan filesystem path dan bukan value yang dapat diberikan request. Component berada di examples/resources/js/pages/Panels/Admin/Columns/AccountAge.vue:
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{ state: unknown }>();
/** A year of membership fills the bar; past that it simply stays full. */
const FULL_AT_DAYS = 365;
const reading = computed(() => {
const value = props.state;
if (typeof value !== 'object' || value === null) {
return null;
}
const { days, label } = value as { days?: unknown; label?: unknown };
if (typeof days !== 'number' || typeof label !== 'string') {
return null;
}
return { days, label };
});
const percent = computed(() =>
reading.value === null
? 0
: Math.min(100, Math.round((reading.value.days / FULL_AT_DAYS) * 100)),
);
</script>
<template>
<div v-if="reading" class="flex flex-col gap-1">
<span class="text-xs whitespace-nowrap text-muted-foreground">
{{ reading.label }}
</span>
<div
class="h-1.5 w-full overflow-hidden rounded-full bg-muted"
role="img"
:aria-label="`Account age: ${reading.label}`"
>
<div
class="h-full rounded-full bg-primary"
:style="{ width: `${percent}%` }"
/>
</div>
</div>
<span v-else class="text-muted-foreground">—</span>
</template>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
State tiba sebagai untyped JSON lalu di-narrow, bukan di-assert secara buta. Shape yang tidak cocok menghasilkan empty cell, bukan exception di tengah table.
Filters
Ada enam filter pada example, mencakup seluruh jenis filter framework kecuali boolean filter.
TernaryFilter::make('verified')
->label('Email verification')
->column('email_verified_at')
->nullable() // set versus not set, rather than 1 versus 0
->labels('Verified', 'Unverified', 'Anyone'),
TernaryFilter::make('is_admin')
->label('Role')
->labels('Administrators', 'Members', 'Anyone'),
DateFilter::make('registered')
->label('Registered between')
->column('created_at'),
// More than one answer, so it is a form rather than a control.
FormFilter::make('passkeyActivity')
->label('Passkey activity')
->form(static fn (FormSchema $schema): FormSchema => $schema->schema([
Select::make('has')
->label('Passkeys')
->options(['yes' => 'Has passkeys', 'no' => 'None registered']),
DatePicker::make('usedSince')->label('Used since'),
]))
->query(static function (Builder $query, mixed $data): void {
// …
}),
QueryBuilderFilter::make('conditions')
->label('Advanced')
->maxRules(5)
->constraints([
TextConstraint::make('name'),
TextConstraint::make('email'),
DateConstraint::make('created_at')->label('Registered'),
DateConstraint::make('email_verified_at')->label('Verified at'),
BooleanConstraint::make('is_admin')->label('Administrator'),
]),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
Semua filter adalah whitelist. Nama filter yang tidak pernah dideklarasikan schema tidak dianggap ada. Rule QueryBuilderFilter yang menyebut constraint yang tidak pernah ditawarkan ditolak. Ternary value di luar tiga value yang diterima tidak memodifikasi query. tests/Feature/Panel/Negative/HostileTableInputTest.php menyatakan masing-masing sebagai sesuatu yang tidak boleh terjadi.
Actions
Header Action, juga digunakan kembali pada empty state:
private static function headerActions(): array
{
return [
// The full form in a dialog, so adding one user does not cost a page.
CreateAction::modal(UserResource::class)->label('New user'),
ImportAction::make(UserImporter::class, UserResource::class),
ExportAction::make(UserExporter::class, UserResource::class),
];
}2
3
4
5
6
7
8
9
CreateAction::make() mengarah ke create page; CreateAction::modal() membuka form yang sama di dialog. Keduanya didukung dan Resource tetap memiliki create page meskipun modal digunakan di table.
Record actions mencakup replicate yang secara eksplisit menolak menyalin dua attribute yang tidak boleh diduplikasi:
->recordActions([
ViewAction::make(UserResource::class)->icon('info'),
EditAction::make(UserResource::class)->icon('pencil'),
ReplicateAction::make(
UserResource::class,
except: ['email', 'email_verified_at'],
using: static function (Model $copy, Model $original): void {
$copy->forceFill([
'name' => $original->getAttribute('name').' (copy)',
'email' => 'copy-'.Str::random(8).'@example.test',
]);
},
),
DeleteAction::make(UserResource::class)->icon('trash'),
])2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static function make(
string $resource,
array $except = [],
?Closure $using = null,
): Action2
3
4
5
Email bersifat unique dan status verification tidak boleh ikut diduplikasi karena copy account belum pernah melakukan verifikasi apa pun.
Bulk Action berikut meng-authorize seluruh record sebelum menulis satu pun:
Action::make('verify')
->label('Mark as verified')
->icon('check')
->variant(ActionVariant::Outline)
->authorizeEachUsing(static fn (Model $record): bool => self::actorIsAdmin())
->authorize(static fn (?Model $record): bool => self::actorIsAdmin())
->successMessageUsing(static fn (int $count): string => $count === 1
? '1 account marked as verified.'
: "{$count} accounts marked as verified.")
->bulkAction(static function (Collection $records): void {
$records
->filter(static fn (Model $record): bool => $record->getAttribute('email_verified_at') === null)
->each(static fn (Model $record) => $record
->forceFill(['email_verified_at' => now()])
->save());
}),2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
authorize() adalah gate Action secara keseluruhan. authorizeEachUsing() ditanyakan untuk setiap selected record sebelum ada record yang ditulis. Jika selection memiliki satu account yang ditolak, tidak ada account lain yang sempat berubah.
Toolbar Action berikut bekerja pada table secara keseluruhan, bukan selection:
Action::make('purgeUnverified')
->label('Purge unverified')
->icon('trash-2')
->variant(ActionVariant::Ghost)
->requiresConfirmation(
heading: 'Delete every unverified account?',
description: 'Accounts that never confirmed their email address are removed. This cannot be undone.',
button: 'Delete them',
)
->authorize(static fn (): bool => self::actorIsAdmin())
->successMessageUsing(static fn (int $count): string => 'Unverified accounts removed.')
->tableAction(static function (): void {
User::query()
->whereNull('email_verified_at')
->whereKeyNot(auth()->id())
->delete();
}),2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
tableAction() membuat Action executable tanpa record. recordAction, bulkAction, dan tableAction adalah tiga whitelist berbeda. Action yang dideklarasikan sebagai salah satunya tidak otomatis dapat dipanggil melalui endpoint jenis lain, apa pun nama yang ditulis request.
Infolist
Infolists/UserInfolist.php adalah schema yang dirender view page. Tanggung jawab pertamanya justru bersifat negatif: password tidak ada, bukan sekadar dimasking. Infolist yang tidak pernah membaca password tidak dapat membocorkannya. View yang diturunkan dari form hanya bisa berjanji melakukan filtering.
return $schema
->columns(2)
->actions([/* an action on the record page */])
->schema([
Tabs::make([
Tab::make('Account')->icon('user')->columns(2)->schema([
Section::make('Identity')
->columns(2)
->headerActions([/* resend verification */])
->schema([
TextEntry::make('name'),
TextEntry::make('email'),
BadgeEntry::make('role')
->label('Role')
->formatUsing(static fn (mixed $value, Model $record): string => $record instanceof User && $record->is_admin
? 'Administrator'
: 'Member')
->colors(['Administrator' => BadgeColor::Info]),
BooleanEntry::make('email_verified_at')
->label('Email verified')
->labels('Verified', 'Unverified'),
]),
// …
]),
Tab::make('Security')->icon('shield')->schema([
Section::make('Passkeys')
->description('Devices this account can sign in with.')
->schema([
RepeatableEntry::make('passkeys')
->label('Registered')
->itemLabel('Passkey')
->placeholder('No passkeys registered.')
->schema([
Grid::make(3)->schema([
TextEntry::make('name'),
DateTimeEntry::make('created_at')->label('Added'),
DateTimeEntry::make('last_used_at')
->label('Last used')
->since()
->placeholder('Never'),
]),
]),
]),
// …
]),
])->persistTab(),
]);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
RepeatableEntry menggambar satu copy schema untuk setiap related record. persistTab() menyimpan tab aktif pada URL, sehingga link langsung ke security tab seorang user dapat dibagikan.
Action yang dideklarasikan pada infolist di-resolve terhadap infolist schema, bukan table schema. /admin/actions/infolist adalah endpoint terpisah dengan whitelist terpisah. Action yang tampil pada satu page tidak boleh otomatis runnable dari page lain:
Action::make('note')
->label('Add a note')
->icon('pencil')
->variant(ActionVariant::Outline)
->modalHeading('Note about this account')
->modalSubmitLabel('Save note')
->modalWidth(ModalWidth::Large)
->slideOver()
->successMessage('Note saved.')
->schema(static fn (?Model $record): FormSchema => FormSchema::make()->schema([
Textarea::make('note')->label('Note')->rows(6)->required()->maxLength(1000),
]))
->authorize(static fn (?Model $record): bool => $record !== null && self::actorIsAdmin())
->action(static function (Model $record, array $data): void {
logger()->info('Panel note', [
'user' => $record->getKey(),
'note' => $data['note'] ?? '',
'by' => auth()->id(),
]);
}),2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
schema() memberikan form pada Action. Value form diterima sebagai argument kedua action(), sudah divalidasi dan dipersempit hanya ke field yang dideklarasikan schema.
Pages
Empat class, masing-masing hampir satu baris:
final class ListUsers extends ListRecords
{
protected static string $resource = UserResource::class;
}2
3
4
CreateUser, ViewUser, dan EditUser masing-masing meng-extend CreateRecord, ViewRecord, dan EditRecord. Di sinilah lifecycle hook dan per-page header action dapat ditambahkan. Example tidak membutuhkan keduanya sehingga class tetap seperti hasil generator.
Policy
examples/app/Policies/UserPolicy.php adalah Laravel policy biasa. Tidak ada bagian policy yang mengetahui Panel itu ada. Ini sengaja: policy yang sama harus tetap berlaku pada console command, API controller, dan Panel.
final class UserPolicy
{
public function viewAny(User $user): bool
{
return $user->is_admin;
}
public function view(User $user, User $record): bool
{
return $user->is_admin || $user->is($record);
}
public function create(User $user): bool
{
return $user->is_admin;
}
public function update(User $user, User $record): bool
{
return $user->is_admin || $user->is($record);
}
public function delete(User $user, User $record): bool
{
return $user->is_admin && ! $user->is($record);
}
public function deleteAny(User $user): bool
{
return $user->is_admin;
}
public function restore(User $user, User $record): bool
{
return $user->is_admin;
}
public function restoreAny(User $user): bool
{
return $user->is_admin;
}
public function forceDelete(User $user, User $record): bool
{
return $user->is_admin && ! $user->is($record);
}
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
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
Ada dua rule yang penting disebut secara eksplisit.
Member dapat membaca dan mengedit record miliknya sendiri dan tidak dapat menyentuh account lain. "Bukan milik saya" menghasilkan 403, bukan sekadar hidden row, sehingga guessed URL ditolak oleh rule yang sama dengan rule yang menyembunyikan link.
Administrator tidak dapat menghapus account miliknya sendiri melalui Panel. Mengunci administrator terakhir keluar dari sistem adalah jenis kesalahan yang cukup terjadi sekali; policy adalah tempat termurah untuk mencegahnya.
Model
examples/app/Models/User.php menunjukkan tiga hal yang diminta Panel dari user model dan satu hal yang tidak diminta:
class User extends Authenticatable implements MustVerifyEmail, PanelNotifiable, PanelUser, PasskeyUser
{
use HasFactory;
use Notifiable; // the notification centre
use PasskeyAuthenticatable;
use TwoFactorAuthenticatable; // the security settings page
/**
* `is_admin` is deliberately absent: registration and profile updates
* both fill from request input, and a privilege flag that is
* mass-assignable is a privilege anyone can grant themselves.
*
* @var list<string>
*/
protected $fillable = ['name', 'email', 'password'];
/**
* A rule about the account, asked on every panel request alongside the
* panel's own predicate. Both must agree.
*/
public function canAccessPanel(Panel $panel): bool
{
return $this->hasVerifiedEmail();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
PandaPanel\Contracts\PanelUser mendeklarasikan satu method dan berlaku pada seluruh Panel sekaligus. Closure canAccess() milik Admin Panel adalah rule tentang Panel tersebut. Keduanya harus sama-sama mengizinkan; satu rule tidak dapat membatalkan penolakan rule lain.
Perhatikan bahwa is_admin sengaja tidak ada di $fillable. Menaikkan privilege account harus merupakan explicit forceFill atau dedicated Action, bukan side effect dari generic form save.
Export dan import
Exports/UserExporter.php dan Imports/UserImporter.php dipasang pada header/bulk actions table. Exporter tidak memiliki column password dan tidak akan pernah meng-export-nya. Importer juga tidak menerima password dari spreadsheet; account baru diberi password random sehingga masuk ke reset flow seperti user lain. Keduanya dibedah lebih lengkap di Import dan Export.
Test
| File | Yang di-assert |
|---|---|
tests/Feature/Panel/AdminPanelExampleTest.php | Dashboard merender, navigation dibangun dari discovered class mengikuti urutan group, dan seluruh user lifecycle bekerja untuk administrator |
tests/Feature/Panel/UsersTableAppliedTest.php | Sebelas column, summary terhadap seluruh query bukan hanya current page, serta kombinasi group/filter/search/sort secara bersamaan |
tests/Feature/Panel/Negative/PrivilegeEscalationTest.php | Guessed URL, hand-written POST, maupun swapped ID tidak dapat melewati policy |
tests/Feature/Panel/ImportExportTest.php | Exporter tidak pernah menawarkan password dan row yang di-upload ulang melakukan update, bukan duplicate |
Contoh representative:
<?php
declare(strict_types=1);
use App\Models\User;
use App\Panels\Admin\Resources\Users\UserResource;
use PandaPanel\Core\PanelManager;
beforeEach(function (): void {
app(PanelManager::class)->setCurrentPanel(panel('admin'));
$this->admin = User::factory()->admin()->create(['name' => 'Ada Lovelace']);
$this->actingAs($this->admin);
});
it('shows the resource, its form and its actions to an administrator', function (): void {
panelTable(UserResource::class)->assertCanSeeRecord($this->admin)->assertCount(1);
panelForm(UserResource::class)->assertFieldIsRequired('name');
panelRecordActions(UserResource::class)->assertExists('edit');
});
it('makes the password required on create and optional on edit', function (): void {
panelForm(UserResource::class, 'create')->assertFieldIsRequired('password');
panelForm(UserResource::class, 'edit')
// A blank password is dropped rather than written, so the stored
// hash is not overwritten with an empty string.
->assertDehydratesTo(['name' => 'Ada', 'password' => ''], ['name' => 'Ada']);
});
it('refuses a member a record that is not their own', function (): void {
$member = User::factory()->create();
$other = User::factory()->create();
$this->actingAs($member)->get("/admin/users/{$other->id}")->assertForbidden();
$this->actingAs($member)->get("/admin/users/{$member->id}")->assertOk();
});
it('does not let an administrator toggle the admin flag on their own account', function (): void {
$this->post('/admin/actions/cell', [
'resource' => 'users',
'column' => 'is_admin',
'record' => $this->admin->id,
'value' => false,
]);
expect($this->admin->fresh()->is_admin)->toBeTrue();
});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
php artisan test --compact --filter=UsersTableApplied
php artisan test --compact --filter=AdminPanelExample2
Hal yang perlu diperhatikan
$withsangat penting pada example ini. Hapus['passkeys']dan passkey-name column akan melakukan lazy load per row. DenganModel::shouldBeStrict()aktif, ini menjadi exception alih-alih page yang hanya terasa lambat.ToggleColumndanTextInputColumnmelakukan write melalui endpoint tersendiri./admin/actions/cellmengulang pemeriksaanupdatepada record dan mengulangdisabledUsing(). Control yang terlihat pada browser tidak pernah menjadi security control.CustomColumnmembutuhkan build. Component registry menggunakanimport.meta.globpada build time. File.vuebaru tidak terlihat sampai Vite menjalankan build/dev server lagi.getPage()hanyacreate,edit, atauview. Membandingkan dengan value lain akan diam-diam mengambil branch selain create pada contoh ini.dehydrateTo()mengubah attribute tujuan, bukan nama field. Validation message dan condition tetap menggunakanverified; hanya write yang diarahkan keemail_verified_at.- Infolist action bukan table action. Keduanya menggunakan endpoint dan whitelist berbeda. Memindahkan Action dari satu schema ke schema lain mengubah endpoint mana yang boleh menjalankannya.
- User model example sengaja mengeluarkan
is_admindari$fillable. Promosi account harus melalui explicitforceFillatau dedicated Action, tidak pernah sebagai side effect generic form save.
Lihat juga
- Admin Panel Example — Panel yang menemukan Resource ini
- Product Resource — alur Resource yang sama dibangun dari nol
- Import and Export — exporter dan importer yang dipasang Resource ini
- Custom Field — extension seam yang sama seperti
AccountAge.vue, tetapi pada form - Creating Resources
- Tables Overview, Columns, Filters
- Editable Columns
- Forms Overview, State Lifecycle
- Infolists Overview
- Actions Overview, Bulk Actions
- Resource Authorization
- Custom Columns
- Negative Security Tests