Soft Deletes
Resource yang record-nya masuk trash alih-alih langsung dihancurkan membutuhkan tiga hal sekaligus: record page yang dapat membuka deleted record, Action yang dapat mengembalikan atau menghapusnya secara permanen, dan filter yang dapat menampilkan deleted record pada table. Satu property mengaktifkan behavior dasar untuk ketiganya. Gunakan fitur ini ketika model memakai Illuminate\Database\Eloquent\SoftDeletes dan Panel memang dimaksudkan untuk mengekspos status deleted tersebut.
Resource dengan soft delete
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Posts;
use App\Models\Post;
use App\Panels\Admin\Resources\Posts\Pages\EditPost;
use App\Panels\Admin\Resources\Posts\Pages\ListPosts;
use PandaPanel\Actions\DeleteAction;
use PandaPanel\Actions\DeleteBulkAction;
use PandaPanel\Actions\ForceDeleteAction;
use PandaPanel\Actions\ForceDeleteBulkAction;
use PandaPanel\Actions\RestoreAction;
use PandaPanel\Actions\RestoreBulkAction;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\FormSchema;
use PandaPanel\Resources\Resource;
use PandaPanel\Tables\Columns\TextColumn;
use PandaPanel\Tables\Filters\TrashedFilter;
use PandaPanel\Tables\TableSchema;
final class PostResource extends Resource
{
protected static string $model = Post::class;
protected static bool $softDeletes = true;
public static function table(TableSchema $table): TableSchema
{
return $table
->columns([TextColumn::make('title')->searchable()->sortable()])
->filters([TrashedFilter::make('trashed')])
->recordActions([
DeleteAction::make(self::class),
RestoreAction::make(self::class),
ForceDeleteAction::make(self::class),
])
->bulkActions([
DeleteBulkAction::make(self::class),
RestoreBulkAction::make(self::class),
ForceDeleteBulkAction::make(self::class),
]);
}
public static function form(FormSchema $schema): FormSchema
{
return $schema->schema([TextInput::make('title')->required()]);
}
/**
* @return array<string, class-string>
*/
public static function pages(): array
{
return [
'index' => ListPosts::class,
'edit' => EditPost::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
Generator menghasilkan bentuk tersebut secara langsung:
php artisan make:panel-resource Post --panel=Admin --soft-deletesDeklarasi
protected static bool $softDeletes = false;
public static function usesSoftDeletes(): bool;2
3
Soft delete harus dideklarasikan dan benar-benar didukung model. usesSoftDeletes() hanya mengembalikan true ketika Resource menyatakan soft delete aktif dan class_uses_recursive() menemukan trait SoftDeletes pada model:
public static function usesSoftDeletes(): bool
{
if (! static::$softDeletes) {
return false;
}
return in_array(SoftDeletes::class, class_uses_recursive(static::getModel()), true);
}2
3
4
5
6
7
8
Mendeteksi hanya dari model akan membuat Resource diam-diam mendapatkan restore Action hanya karena model menggunakan SoftDeletes, padahal Panel belum tentu ingin mengekspos behavior tersebut. Dua Resource yang menggunakan model sama dapat berbeda: satu mendeklarasikan soft delete dan satu tidak.
Tiga konsekuensi utama
1. Record page dapat menjangkau trashed record
Seluruh lookup record melewati recordQuery(), yaitu query() dengan satu pengecualian sempit:
protected static function recordQuery(): Builder
{
$query = static::query();
if (static::usesSoftDeletes()) {
$query->withoutGlobalScope(SoftDeletingScope::class);
}
return $query;
}2
3
4
5
6
7
8
9
10
Hanya SoftDeletingScope yang dilepas. Tenant, module, permission, dan scope lainnya tetap berlaku sama seperti pada live record. Karena itu trashed record yang berada di luar Resource scope tetap menghasilkan 404.
Tanpa behavior ini, deleted record tidak pernah dapat dibuka dan akibatnya tidak pernah dapat direstore: satu-satunya jalur menuju record tersebut disembunyikan default scope.
| Method | Signature | Dapat melihat trashed |
|---|---|---|
resolveRecord() | public static function resolveRecord(int|string $key): Model | ya |
findRecord() | public static function findRecord(int|string $key): ?Model | ya |
findRecords() | public static function findRecords(array $keys): Collection | ya |
query() | public static function query(): Builder | tidak — scope index tetap normal |
use App\Panels\Admin\Resources\Posts\PostResource;
$post = PostResource::resolveRecord($trashedKey); // resolves
PostResource::query()->find($trashedKey); // null — the index does not see it2
3
4
2. Restore dan force-delete Action dapat bekerja
Action endpoint melakukan lookup melalui Resource::findRecord(), yang memahami trashed record karena alasan yang sama. findRecords() juga penting untuk bulk operation: endpoint membandingkan jumlah record yang berhasil di-load dengan jumlah key yang dikirim. Jika lookup tidak dapat melihat trashed record, seluruh selection akan menghasilkan 404 sebelum Action sempat restore apa pun.
3. TrashedFilter memiliki record yang dapat ditampilkan
Index tetap menyembunyikan trashed record sampai filter meminta untuk menampilkannya. Inilah perbedaan antara list dan record lookup: index menampilkan record yang current/live, sedangkan record page diminta menjawab satu record tertentu berdasarkan key.
Trashed filter
PandaPanel\Tables\Filters\TrashedFilter adalah ordinary select filter dengan vocabulary yang sudah ditentukan.
use PandaPanel\Tables\Filters\TrashedFilter;
$table->filters([TrashedFilter::make('trashed')]);2
3
| Constant | Value | Option label | Query |
|---|---|---|---|
TrashedFilter::WITHOUT | 'without' | Hidden | Default scope tidak diubah |
TrashedFilter::WITH | 'with' | Included | withoutGlobalScope(SoftDeletingScope::class) |
TrashedFilter::ONLY | 'only' | Only deleted | Scope dilepas lalu whereNotNull(deleted_at) |
/admin/posts?filters[trashed]=onlyDefault label adalah Deleted records dan placeholder-nya Hidden. Ubah label menggunakan ->label('Trash') seperti filter biasa.
Tiga behavior penting:
sanitize()menolak value lain. Value?filters[trashed]=yang tidak dikenal menjadi no-op, bukan cara memperlebar query.- Scope dilepas secara manual daripada memakai macro
withTrashed(), karena macro tersebut hanya tersedia pada builder yang sudah diperluas trait. - Pada model tanpa soft delete, filter tidak melakukan apa pun.
constrain()memeriksagetQualifiedDeletedAtColumn()terlebih dahulu sehingga salah deklarasi menghasilkan filter inert, bukan error 500.
Lihat Filters.
Actions
Setiap Action dibuat melalui factory yang mengembalikan configured PandaPanel\Actions\Action. Factory menerima Resource agar dapat menanyakan ability milik Resource tersebut.
Record actions
use PandaPanel\Actions\DeleteAction;
use PandaPanel\Actions\ForceDeleteAction;
use PandaPanel\Actions\RestoreAction;
DeleteAction::make(PostResource::class);
RestoreAction::make(PostResource::class);
ForceDeleteAction::make(PostResource::class);2
3
4
5
6
7
| Action | Name | Label | Terlihat ketika | Diotorisasi oleh | Menjalankan |
|---|---|---|---|---|---|
DeleteAction | delete | Delete | selalu | Resource::canDelete() | $record->delete() |
RestoreAction | restore | Restore | record trashed | Resource::canRestore() | TrashedRecord::restore() |
ForceDeleteAction | forceDelete | Delete permanently | record trashed | Resource::canForceDelete() | TrashedRecord::forceDelete() |
Restore dan force delete disembunyikan untuk record yang masih live, sehingga row menampilkan restore atau delete sesuai state-nya, bukan keduanya. DeleteAction dan ForceDeleteAction sama-sama meminta confirmation; RestoreAction tidak.
Bulk actions
use PandaPanel\Actions\DeleteBulkAction;
use PandaPanel\Actions\ForceDeleteBulkAction;
use PandaPanel\Actions\RestoreBulkAction;
DeleteBulkAction::make(PostResource::class);
RestoreBulkAction::make(PostResource::class);
ForceDeleteBulkAction::make(PostResource::class);2
3
4
5
6
7
| Action | Name | Collective ability | Per-record ability |
|---|---|---|---|
DeleteBulkAction | delete | canDeleteAny() | canDelete() |
RestoreBulkAction | restore | canRestoreAny() | canRestore() |
ForceDeleteBulkAction | forceDelete | canForceDeleteAny() | canForceDelete() |
Ada dua tingkat authorization dan keduanya wajib. Collective ability ditanyakan sebelum ada record tertentu yang dapat diperiksa; check inilah yang menentukan apakah button ditampilkan. Setelah selection dikirim, setiap record tetap di-authorize satu per satu sebelum satu pun write dilakukan. Selection yang mengandung satu record terlarang tidak mengubah apa pun: loop menghasilkan 403 sebelum transaction dibuka.
Setiap bulk Action membuka DB::transaction() sendiri tanpa bergantung pada transaction setting milik Panel. "Semua atau tidak sama sekali" adalah bagian dari kontrak Action tersebut.
Jika restore selection berisi record yang ternyata masih live, record tersebut hanya dibiarkan tetap seperti semula, bukan ditolak. User meminta selection berada pada keadaan restored, dan record live sudah berada pada keadaan tersebut.
Abilities
Enam method Resource mendelegasikan pemeriksaan ke Gate melalui PandaPanel\Support\PolicyGate:
public static function canDelete(Model $record): bool; // 'delete'
public static function canDeleteAny(): bool; // 'deleteAny'
public static function canRestore(Model $record): bool; // 'restore'
public static function canRestoreAny(): bool; // 'restoreAny'
public static function canForceDelete(Model $record): bool; // 'forceDelete'
public static function canForceDeleteAny(): bool; // 'forceDeleteAny'2
3
4
5
6
Policy tetap Laravel policy biasa dan tidak perlu mengetahui keberadaan Panel.
final class PostPolicy
{
public function delete(User $user, Post $post): bool
{
return $user->is_admin;
}
public function deleteAny(User $user): bool
{
return $user->is_admin;
}
public function restore(User $user, Post $post): bool
{
return $user->is_admin;
}
public function restoreAny(User $user): bool
{
return $user->is_admin;
}
public function forceDelete(User $user, Post $post): 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
20
21
22
23
24
25
26
27
28
29
30
31
32
deleteAny, restoreAny, dan forceDeleteAny bukan Laravel convention standar. Ketiganya adalah collective ability yang digunakan bulk Action. Pada strict authorization, policy yang tidak memiliki salah satu method tersebut menghasilkan PanelAuthorizationException yang menyebut ability yang hilang, bukan terlihat seperti denial biasa.
Menanyakan status soft delete pada record
PandaPanel\Support\TrashedRecord menjawab pertanyaan soft-delete terhadap model yang mungkin sama sekali tidak menggunakan soft delete. Resource Action dan relation Action sama-sama memakai helper ini sehingga tidak ada dua implementation yang dapat berbeda behavior.
use PandaPanel\Support\TrashedRecord;
TrashedRecord::supports($record); // bool — does it answer trashed() at all
TrashedRecord::isTrashed($record); // bool — false for a model without the trait
TrashedRecord::restore($record); // no-op unless trashed and restorable
TrashedRecord::forceDelete($record); // always safe: plain delete on a plain model2
3
4
5
6
Check dilakukan per method, bukan hanya melalui class_uses_recursive(). Model dapat mengimplementasikan soft-delete behavior dengan cara sendiri, sehingga pertanyaan yang lebih tepat adalah apakah model mampu menjawab operasi tersebut, bukan trait implementation mana yang dipakainya.
Flag generator
php artisan make:panel-resource Post --panel=Admin --soft-deletes--soft-deletes menulis deklarasi, filter, dan Action secara bersamaan:
| File | Yang ditambahkan |
|---|---|
PostResource.php | protected static bool $softDeletes = true; |
Tables/PostsTable.php | TrashedFilter::make('trashed') di filters() |
Tables/PostsTable.php | RestoreAction dan ForceDeleteAction di recordActions() |
Tables/PostsTable.php | RestoreBulkAction dan ForceDeleteBulkAction di bulkActions() |
Membuat restore Action tanpa filter menghasilkan button yang secara praktik tidak pernah dapat muncul karena trashed record tidak pernah sampai ke table. Inilah alasan generator menambahkan keduanya sebagai satu paket. Lihat make:panel-resource.
Relation managers
Relation manager memiliki deklarasi soft delete sendiri dengan aturan yang sama:
protected static bool $softDeletes = false;
public static function usesSoftDeletes(Model $owner): bool;2
3
Related model juga harus benar-benar menggunakan trait dan resolveRecord() milik manager melepas SoftDeletingScope ketika kedua syarat terpenuhi. Restore dan force-delete bulk Action hanya visible ketika usesSoftDeletes($owner) true. Lihat Soft deleted relations.
Catatan penting
- Mendeklarasikan
$softDeletespada model tanpa trait bersifat inert, bukan error.usesSoftDeletes()false, lookup tidak berubah, dan restore Action tidak pernah menjadi visible karena tidak ada record yang melaporkan dirinya trashed. - Filter saja dapat menampilkan trashed row tetapi tidak menyediakan operasi untuk memulihkannya; Action saja tidak pernah muncul karena trashed row tidak terlihat. Keduanya diperlukan dan generator memang membuat keduanya.
- Index tidak pernah diperlebar hanya karena
$softDeletes = true. Hanya filter yang dapat memperlebar index, dan hanya menggunakan tiga value yang didefinisikannya. - Confirmation text
DeleteActionmenyatakan removal tidak dapat dibatalkan. Action yang sama dipakai Resource biasa dan soft-deleting Resource. Berikan copy sendiri melalui->requiresConfirmation(heading: ..., description: ..., button: ...)bila teks perlu menjelaskan recoverable delete secara lebih tepat. - Delete tidak memiliki page lifecycle hook. Proses berjalan melalui Action endpoint tanpa page instance. Gunakan
Action::before()danAction::after(), yang berjalan di transaction milik Action. Lihat Lifecycle hooks. - Record yang direstore langsung keluar dari view "only deleted". Action melakukan redirect dengan
back(), filter tetap berada pada URL, lalu query yang sama dijalankan terhadap state record yang sudah berubah. - Trashed record tetap tidak ikut global search, karena search dimulai dari
Resource::query()dan bukanrecordQuery().