Bulk Action
Bulk action adalah operasi yang dijalankan terhadap beberapa row yang dipilih pengguna. Gunakan fitur ini ketika operasi yang sama perlu diterapkan ke banyak record dan menjalankannya satu per satu bukan alur yang tepat — misalnya menghapus sekumpulan record, menyetujui antrean, atau mengekspor record terpilih.
Mendeklarasikan bulk action juga otomatis mengaktifkan pemilihan row. Bulk action tanpa kemampuan memilih row memang tidak akan berguna.
Contoh minimal
use App\Panels\Admin\Resources\Orders\OrderResource;
use PandaPanel\Actions\DeleteBulkAction;
use PandaPanel\Tables\Columns\TextColumn;
use PandaPanel\Tables\TableSchema;
public static function table(TableSchema $table): TableSchema
{
return $table
->columns([TextColumn::make('reference')->searchable()])
->bulkActions([
DeleteBulkAction::make(OrderResource::class),
]);
}2
3
4
5
6
7
8
9
10
11
12
13
Tabel sekarang memiliki kolom checkbox, checkbox select all pada header, dan sebuah action bar yang muncul ketika ada row yang dipilih.
Selection
| Method | Signature | Default |
|---|---|---|
bulkActions() | bulkActions(array $actions): self | [], menjalankan selectable(true) ketika tidak kosong |
selectable() | selectable(bool $selectable = true): self | false |
isSelectable() | isSelectable(): bool | — |
getBulkActions() | getBulkActions(): list<Action> | — |
getBulkAction() | getBulkAction(string $name): ?Action | — |
Memanggil selectable() tanpa bulk action membuat tabel memiliki checkbox tanpa bulk bar. Ini berguna jika frontend memiliki perilaku lain terhadap selection. Dalam penggunaan normal, Anda biasanya tidak perlu memanggilnya secara langsung.
Mendeklarasikan dua bulk action dengan nama yang sama melempar PanelSchemaException::duplicateActions('bulk actions', …). Mendeklarasikan action yang sama sekali tidak memiliki handler melempar PanelSchemaException::inertAction().
Bulk action bawaan
use App\Panels\Admin\Resources\Orders\Exports\OrderExporter;
use App\Panels\Admin\Resources\Orders\OrderResource;
use PandaPanel\Actions\DeleteBulkAction;
use PandaPanel\Actions\ExportAction;
use PandaPanel\Actions\ForceDeleteBulkAction;
use PandaPanel\Actions\RestoreBulkAction;
$table->bulkActions([
DeleteBulkAction::make(OrderResource::class),
RestoreBulkAction::make(OrderResource::class),
ForceDeleteBulkAction::make(OrderResource::class),
ExportAction::bulk(OrderExporter::class, OrderResource::class),
]);2
3
4
5
6
7
8
9
10
11
12
13
| Factory | Name | Otorisasi | Konfirmasi |
|---|---|---|---|
DeleteBulkAction::make(string $resource): Action | delete | canDeleteAny(), lalu canDelete() per record | ya |
RestoreBulkAction::make(string $resource): Action | restore | canRestoreAny(), lalu canRestore() per record | tidak |
ForceDeleteBulkAction::make(string $resource): Action | forceDelete | canForceDeleteAny(), lalu canForceDelete() per record | ya |
ExportAction::bulk(string $exporter, string $resource): Action | export | canViewAny() | membuka form |
Ketiga action destruktif mengikuti pola yang sama: policy kolektif diperiksa sebelum ada record individual yang diproses, kemudian setiap record diperiksa otorisasinya satu per satu, dan baru setelah seluruh pemeriksaan lolos perubahan ditulis. Seluruh operasi berjalan dalam satu DB::transaction() eksplisit terlepas dari pengaturan transaction milik panel. Jika satu record saja dilarang, tidak ada record yang diubah — bukan mengubah yang diizinkan lalu gagal di tengah jalan.
RestoreBulkAction membiarkan record yang sudah aktif apa adanya, bukan menolaknya. Pengguna memilih sekumpulan record dan meminta semuanya berada dalam kondisi restored; record yang sudah aktif sudah berada pada kondisi tersebut.
Membuat bulk action kustom
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Actions\Action;
use PandaPanel\Actions\Enums\ActionVariant;
Action::make('verify')
->label('Mark as verified')
->icon('check')
->variant(ActionVariant::Outline)
->requiresConfirmation(
heading: 'Verify the selected accounts?',
description: 'Each account will be marked as verified.',
button: 'Verify',
)
->authorize(static fn (?Model $record): bool => auth()->user()?->is_admin === true)
->authorizeEachUsing(static fn (Model $record): bool => $record->email_verified_at === null)
->successMessageUsing(static fn (int $count): string => $count === 1
? '1 account verified.'
: "{$count} accounts verified.")
->bulkAction(static function (Collection $records): void {
$records->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
17
18
19
20
21
22
23
24
| Method | Signature | Catatan |
|---|---|---|
bulkAction() | bulkAction(Closure $callback): static | fn (Collection $records, array $data): void |
authorize() | authorize(Closure $callback): static | fn (?Model $record): bool, dipanggil dengan null pada konteks ini |
authorizeEachUsing() | authorizeEachUsing(Closure $callback): static | fn (Model $record): bool, diperiksa untuk setiap record terpilih |
successMessageUsing() | successMessageUsing(Closure $callback): static | fn (int $count): string |
isBulkExecutable() | isBulkExecutable(): bool | apakah bulk handler tersedia |
affectedCount() | affectedCount(): int | jumlah record yang disentuh pada eksekusi terakhir |
Action yang hanya memiliki action() tanpa bulkAction() tetap dapat ditempatkan dalam bulk set. Dalam kasus tersebut, executeBulk() melakukan fallback dengan menjalankan execute() satu kali untuk setiap record, dan setiap record berjalan dalam transaction-nya sendiri.
Dua pertanyaan otorisasi yang berbeda
Keduanya menjawab hal yang berbeda dan keduanya diperiksa.
->authorize(static fn (?Model $record): bool => $user->can('approve orders'))
->authorizeEachUsing(static fn (Model $record): bool => $record->isApprovable())2
authorize()menjawab apakah action secara umum boleh dijalankan. Pada bulk action method ini dipanggil dengannull, karena jawabannya harus tersedia sebelum record dipilih. Pemeriksaan ini menentukan apakah tombol ditampilkan dan endpoint memeriksanya kembali sebelum eksekusi.authorizeEachUsing()menjawab apakah action boleh diterapkan ke masing-masing record. Jika tidak diberikan, pemeriksaan per record melakukan fallback keauthorize($record).executeBulk()memeriksa seluruh collection sebelum handler dijalankan. Satu penolakan menghasilkan 403 dan tidak ada perubahan yang ditulis.
“Boleh menjalankan action ini” tidak sama dengan “boleh menjalankan action ini terhadap seluruh record tersebut”. Sifat all-or-nothing harus diputuskan sebelum penulisan pertama, bukan ditemukan setelah operasi berjalan setengah jalan.
Pesan yang mengetahui jumlah record
$action->successMessageUsing(static fn (int $count): string => "{$count} approved.");executeBulk() menetapkan affected sesuai ukuran selection sebelum handler berjalan, sehingga getSuccessMessage() dapat melaporkan jumlah record. Jika successMessageUsing() tidak diberikan, nilai dari successMessage() digunakan. Jika keduanya tidak diberikan, pesan default adalah "{label} completed.".
Bulk action dengan form
Action yang mendeklarasikan schema() membuka dialog, mengambil form dari endpoint action-form milik panel ketika dialog dibuka, lalu mengirim hasilnya kembali ke endpoint yang sama. ExportAction::bulk() menggunakan pola ini — dialog-nya meminta pilihan kolom dan format ekspor.
use PandaPanel\Forms\Components\Textarea;
use PandaPanel\Forms\FormSchema;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
Action::make('reject')
->schema(static fn (?Model $record): FormSchema => FormSchema::make()->schema([
Textarea::make('reason')->required()->maxLength(500),
]))
->bulkAction(static function (Collection $records, array $data): void {
$records->each(static fn (Model $r) => $r->reject($data['reason']));
});2
3
4
5
6
7
8
9
10
11
12
Schema diambil saat diperlukan, bukan diserialisasi ke halaman sejak awal. Tanpa pendekatan ini, tabel dengan dua puluh record dapat mengirim dua puluh salinan form yang kemungkinan hanya akan dibuka satu kali. $data telah divalidasi dan didehidrasi oleh schema milik action, sehingga handler tidak pernah menerima key yang tidak dideklarasikan schema.
Endpoint
POST {panel}/actions/bulkNama route adalah panel.{panel_id}.actions.bulk. Payload-nya:
| Key | Wajib | Arti |
|---|---|---|
resource | ya | slug resource, di-resolve terhadap registry panel ini |
action | ya | nama bulk action |
records | ya | array key record, 1 sampai 500 item |
parent | untuk nested resource | key record parent |
Pemeriksaan dijalankan dalam urutan berikut:
| Kegagalan | Status |
|---|---|
| slug resource tidak dikenal | 404 |
action tidak dideklarasikan dalam bulkActions() | 404 |
| action tidak memiliki bulk handler maupun record handler | 400 |
authorize() menolak ketika dipanggil dengan null | 403 |
| tidak ada key yang dapat digunakan setelah difilter menjadi scalar | 422 |
ada key di luar Resource::findRecords() | 404 |
| pemeriksaan per record menolak salah satu record | 403 |
Key dideduplikasi dan jumlah hasil dibandingkan dengan jumlah key yang diminta. Karena itu, key di luar scope resource menghasilkan kegagalan yang jelas, bukan eksekusi parsial. Action yang memiliki form mengirim ke POST {panel}/actions/form dengan scope=bulk, lalu berakhir pada executeBulk() yang sama.
Testing
it('deletes the selection', function (): void {
$this->post('/admin/actions/bulk', [
'resource' => 'orders',
'action' => 'delete',
'records' => [$first->getKey(), $second->getKey()],
])->assertRedirect();
expect(Order::query()->count())->toBe(0);
});
it('authorizes every record before touching any', function (): void {
$action = Action::make('approve')
->authorizeEachUsing(static fn (Model $record): bool => $record->name !== 'Two')
->bulkAction(static fn (Collection $records) => $records->each->approve());
expect(fn () => $action->executeBulk($records))
->toThrow(Symfony\Component\HttpKernel\Exception\HttpException::class);
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Hal yang perlu diperhatikan
- 500 record adalah batas maksimum. Endpoint memvalidasi array dengan
max:500. Operasi “pilih semua record yang cocok dengan filter” terhadap puluhan ribu row seharusnya menjadi table action yang memasukkan pekerjaan ke queue, bukan bulk action. - Selection berisi key, bukan query. Memilih semua row pada satu halaman berarti memilih halaman tersebut. Jika seluruh hasil filter perlu diproses, gunakan table action yang bekerja terhadap query hasil filter.
authorize()menerimanullpada bulk action. Closure yang langsung mengakses$recordtanpa memeriksanullakan gagal bahkan pada render pertama.- Bulk action tanpa
authorizeEachUsing()melakukan fallback keauthorize($record). TambahkanauthorizeEachUsing()ketika jawaban kolektif dan jawaban per record memang sengaja berbeda. - Fallback ke
execute()per record berarti satu transaction per record. Jika seluruh batch harus berhasil atau gagal sebagai satu unit, tulisbulkAction()khusus dan bungkus operasinya dalam transaction yang sesuai. - Success message hanya dikirim satu kali setelah seluruh proses selesai. Feedback per record bukan pola yang tepat untuk bulk action.
- Endpoint tidak memiliki segment parent. Nested resource harus mengirim
parent, dan tabel melakukannya secara otomatis.