Filter
Filter adalah satu-satunya komponen yang diizinkan mengubah nilai dari URL menjadi constraint pada query. Gunakan filter ketika tabel perlu menjawab pertanyaan “tampilkan hanya subset tertentu” — misalnya berdasarkan status, rentang tanggal, kondisi ya/tidak, atau sekumpulan kriteria dalam sebuah form. Semua nilai yang dapat diterima filter harus dideklarasikan, dan nilai yang ditolak tidak pernah diteruskan ke builder.
Contoh tabel dengan filter minimal
use PandaPanel\Tables\Filters\DateFilter;
use PandaPanel\Tables\Filters\SelectFilter;
use PandaPanel\Tables\Filters\TernaryFilter;
use PandaPanel\Tables\TableSchema;
return $table
->columns([/* ... */])
->filters([
SelectFilter::make('status')->options([
'open' => 'Open',
'done' => 'Done',
]),
TernaryFilter::make('published_at')
->label('Published')
->nullable()
->labels('Published', 'Draft', 'Anyone'),
DateFilter::make('created')->label('Created between')->column('created_at'),
]);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
State filter disimpan di query string, misalnya ?filters[status]=open&filters[created][from]=2026-01-01. Dua filter tidak boleh memiliki nama yang sama karena state filter di-key berdasarkan nama; kontrol kedua akan menimpa state kontrol pertama.
Jenis filter
| Class | type() | Nilai yang diterima |
|---|---|---|
SelectFilter | select | satu key opsi yang dideklarasikan |
BooleanFilter | boolean | true/false/'1'/'0'/'true'/'false'/1/0 |
TernaryFilter | ternary | 'true' atau 'false' (termasuk sinonim 1/0) |
DateFilter | date | {from?: string, to?: string} dalam format Y-m-d |
TrashedFilter | select | 'without', 'with', 'only' |
FormFilter | form | data tervalidasi dari FormSchema miliknya sendiri |
QueryBuilderFilter | query_builder | daftar rule yang telah dideklarasikan |
String pada kolom type() merupakan case dari PandaPanel\Tables\Enums\FilterType dan digunakan Vue sebagai discriminator untuk memilih renderer filter.
SelectFilter
use PandaPanel\Tables\Filters\SelectFilter;
SelectFilter::make('status')
->label('Order status')
->options(['open' => 'Open', 'shipped' => 'Shipped', 'done' => 'Done'])
->placeholder('Any status');2
3
4
5
6
| Method | Signature |
|---|---|
options() | options(array $options): self — di-key berdasarkan nilai yang disimpan |
placeholder() | placeholder(string $placeholder): self |
Hanya key opsi yang dideklarasikan yang diterima. Nilai yang sebenarnya valid di kolom database tetapi tidak ada dalam options() tetap ditolak, karena daftar opsi merupakan whitelist. Constraint default adalah where($column, '=', $value).
BooleanFilter
use PandaPanel\Tables\Filters\BooleanFilter;
BooleanFilter::make('verified')
->label('Email verification')
->column('email_verified_at')
->nullable()
->labels('Verified', 'Unverified');2
3
4
5
6
7
| Method | Signature | Default |
|---|---|---|
labels() | labels(string $true, string $false): self | Yes, No |
nullable() | nullable(bool $nullable = true): self | false |
nullable() membuat filter memperlakukan kolom sebagai kondisi terisi atau tidak terisi menggunakan whereNotNull / whereNull, bukan membandingkannya dengan true atau false. Ini memungkinkan timestamp nullable digunakan sebagai boolean filter tanpa menambah kolom baru. Tanpa nullable(), constraint default adalah where($column, '=', $value).
TernaryFilter
use Illuminate\Database\Eloquent\Builder;
use PandaPanel\Tables\Filters\TernaryFilter;
TernaryFilter::make('email_verified_at')
->nullable()
->labels('Verified', 'Unverified', 'Anyone')
->default(TernaryFilter::TRUE);
TernaryFilter::make('has_manager')->queries(
static fn (Builder $query) => $query->whereHas('manager'),
static fn (Builder $query) => $query->whereDoesntHave('manager'),
);2
3
4
5
6
7
8
9
10
11
12
| Member | Signature |
|---|---|
TernaryFilter::TRUE | 'true' |
TernaryFilter::FALSE | 'false' |
labels() | labels(string $true, string $false, ?string $blank = null): self — default Yes, No, All |
nullable() | nullable(bool $nullable = true): self |
queries() | queries(Closure $true, Closure $false): self |
Ternary filter memiliki tiga state, dengan state ketiga menjadi jawaban bermakna bagi tabel, bukan sekadar kontrol kosong. Masing-masing cabang dapat memiliki query sendiri, sehingga dua kondisi tidak harus merupakan kebalikan sederhana: “memiliki manager” dan “tidak memiliki manager” dapat menggunakan whereHas dan whereDoesntHave. Nilai 1 dan 0 juga diterima sebagai sinonim karena bentuk tersebut umum muncul pada URL yang di-bookmark untuk kolom boolean.
DateFilter
use PandaPanel\Tables\Filters\DateFilter;
DateFilter::make('registered')->label('Registered between')->column('created_at');2
3
Nilainya berbentuk {from?: string, to?: string} dengan tanggal berformat Y-m-d. Setiap batas tanggal diparse secara ketat dan dibuang jika bukan tanggal yang valid. Dengan demikian, range yang salah tetap dapat menyempit berdasarkan bagian yang valid tanpa membuat request gagal. Jika from dan to terbalik, keduanya ditukar. Constraint menggunakan startOfDay() dan endOfDay(), sehingga kedua batas bersifat inklusif.
TrashedFilter
use PandaPanel\Tables\Filters\TrashedFilter;
TrashedFilter::make('trashed');2
3
| Member | Nilai | Label |
|---|---|---|
TrashedFilter::WITHOUT | 'without' | Hidden |
TrashedFilter::WITH | 'with' | Included |
TrashedFilter::ONLY | 'only' | Only deleted |
Label default adalah Deleted records. Ketika filter tidak diset, scope withoutTrashed bawaan Eloquent tetap berlaku, sehingga tabel tetap menyembunyikan record terhapus sampai pengguna meminta sebaliknya. Mode with dan only mengangkat SoftDeletingScope secara manual alih-alih bergantung pada macro withTrashed(), karena macro tersebut hanya tersedia pada builder yang diperluas oleh trait. Pada model yang tidak menggunakan soft delete, filter ini tidak melakukan apa pun dan tidak melempar error. php artisan make:panel-resource --soft-deletes akan menambahkannya untuk Anda.
FormFilter
use Illuminate\Database\Eloquent\Builder;
use PandaPanel\Forms\Components\DatePicker;
use PandaPanel\Forms\Components\Select;
use PandaPanel\Forms\FormSchema;
use PandaPanel\Tables\Filters\FormFilter;
FormFilter::make('activity')
->label('Passkey activity')
->form(static fn (FormSchema $schema): FormSchema => $schema->schema([
Select::make('has')->options(['yes' => 'Has passkeys', 'no' => 'None registered']),
DatePicker::make('usedSince')->label('Used since'),
]))
->query(static function (Builder $query, mixed $data): void {
if (($data['has'] ?? null) === 'yes') {
$query->whereHas('passkeys');
}
if (is_string($data['usedSince'] ?? null) && $data['usedSince'] !== '') {
$query->whereHas(
'passkeys',
static fn (Builder $q) => $q->whereDate('last_used_at', '>=', $data['usedSince']),
);
}
});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 |
|---|---|
form() | form(Closure $callback): self — fn (FormSchema $schema): FormSchema |
schema() | schema(): FormSchema — schema hasil build untuk inspeksi |
Gunakan FormFilter untuk pertanyaan yang membutuhkan lebih dari satu input. Form di dalamnya adalah FormSchema biasa, sehingga field dirender, divalidasi, dan diserialisasi dengan aturan yang sama seperti form resource. Nilai yang diteruskan ke query() adalah data form yang sudah tervalidasi: key yang tidak dideklarasikan schema dibuang sebelum closure menerima data, dan form yang seluruh field-nya kosong disanitasi menjadi null sehingga tidak mempersempit query.
Form filter tidak memiliki constraint default. Tanpa closure query(), filter tidak mengubah query karena hanya pembuat schema yang mengetahui arti gabungan field-field tersebut.
QueryBuilderFilter
use PandaPanel\Tables\Filters\Constraints\BooleanConstraint;
use PandaPanel\Tables\Filters\Constraints\DateConstraint;
use PandaPanel\Tables\Filters\Constraints\NumberConstraint;
use PandaPanel\Tables\Filters\Constraints\TextConstraint;
use PandaPanel\Tables\Filters\QueryBuilderFilter;
QueryBuilderFilter::make('conditions')
->label('Advanced')
->maxRules(5)
->constraints([
TextConstraint::make('name'),
NumberConstraint::make('total'),
DateConstraint::make('created_at')->label('Created'),
BooleanConstraint::make('is_active'),
]);2
3
4
5
6
7
8
9
10
11
12
13
14
15
Pengguna dapat menyusun rule sendiri, dan setiap rule menunjuk kolom yang dideklarasikan, operator yang didukung, serta nilai yang diterima. Segala sesuatu di luar deklarasi akan dibuang. Pembahasan lengkap tersedia di filter query builder.
Kemampuan yang tersedia pada semua filter
use Illuminate\Database\Eloquent\Builder;
use PandaPanel\Tables\Filters\SelectFilter;
SelectFilter::make('status')
->label('Order status')
->column('orders.status')
->default('open')
->query(static fn (Builder $query, mixed $value) => $query->whereIn('status', [$value, 'pending']))
->modifyBaseQueryUsing(static fn (Builder $query) => $query->withoutGlobalScopes());2
3
4
5
6
7
8
9
| Method | Signature | Catatan |
|---|---|---|
make() | static make(string $name): static | nama menjadi key pada query string |
label() | label(string $label): static | default menggunakan Str::headline() dari nama |
column() | column(string $column): static | default menggunakan nama filter |
query() | query(Closure $callback): static | fn (Builder $query, mixed $value): void, menggantikan constraint default |
modifyBaseQueryUsing() | modifyBaseQueryUsing(Closure $callback): static | berjalan lebih dahulu, di luar grouping constraint |
default() | default(mixed $value): static | nilai yang dipakai ketika request tidak menentukan filter |
Method sisi baca: getName(), getLabel(), getColumn(), hasDefault(), getDefault(), type(), sanitize(mixed $value), indicator(mixed $value), dan toArray().
apply() dan applyBaseQuery() bersifat final pada base class. Keduanya selalu melakukan sanitasi terlebih dahulu, sehingga closure query() kustom hanya menerima nilai yang memang diterima oleh filter.
modifyBaseQueryUsing()
Gunakan ini ketika filter perlu mengubah bentuk dasar query, bukan sekadar mempersempit hasilnya — misalnya mengangkat global scope atau menambahkan join yang kemudian dibaca oleh constraint lain. Modifier ini berjalan sebelum search dan berada di luar grouping tempat constraint biasa diterapkan, karena orWhere pada grup search dapat memperlebar hasil jika base modifier diterapkan di posisi yang salah.
Filter yang hanya mendeklarasikan base-query modifier tidak akan menerapkan constraint biasa. Filter tersebut sudah menyatakan bahwa pekerjaannya berlangsung di base query; meneruskan proses ke constraint default justru dapat menciptakan where terhadap nama filter yang belum tentu merupakan kolom database.
Default filter
SelectFilter::make('status')->options(['open' => 'Open'])->default('open');Nilai default hanya diterapkan ketika request sama sekali tidak menyebut key filters. Begitu request memuat filter apa pun, filter lain yang tidak ada dianggap sengaja dihapus oleh pengguna, bukan belum pernah disetel. Tanpa aturan ini, default filter tidak akan pernah benar-benar dapat dihapus. Filter default tetap dilaporkan sebagai aktif di state()['filters'] karena keputusan tersebut benar-benar diterapkan oleh tabel.
TableSchema::defaultFilters() mengembalikan map seluruh filter yang mendeklarasikan default.
Menghapus filter dan masalah empty map
Query string tidak dapat merepresentasikan array kosong secara langsung, sehingga ?filters= digunakan sebagai bentuk URL untuk mengatakan “key filters ada, tetapi tidak ada filter aktif”. Frontend menulis sentinel tersebut setelah setiap mutasi filter. Tiga kondisi berikut menghasilkan map kosong tetapi memiliki arti yang berbeda:
| Request | Arti | Default diterapkan? |
|---|---|---|
tidak ada key filters sama sekali | belum ada keputusan | ya |
?filters= | pengguna menghapus seluruh filter | tidak |
| tidak ada key, tetapi session mengingat map kosong | pengguna menghapus semuanya pada kunjungan sebelumnya | tidak |
Perilaku filter bar
$table
->deferFilters()
->filtersTrigger('Refine', 'filter')
->filtersApplyLabel('Run')
->filtersResetLabel('Start over')
->showFiltersResetAction(false)
->persistFiltersInSession();2
3
4
5
6
7
| Method | Default |
|---|---|
deferFilters(bool $defer = true) | false — filter langsung berlaku ketika diubah |
filtersTrigger(string $label, ?string $icon = null) | Filters, tanpa icon |
filtersApplyLabel(string $label) | Apply filters |
filtersResetLabel(string $label) | Clear |
showFiltersResetAction(bool $show = true) | true |
persistFiltersInSession(bool $persist = true) | false |
Gunakan deferred filter pada tabel yang cukup mahal sehingga kriteria yang belum selesai tidak seharusnya langsung menjalankan request — misalnya query builder dengan lima rule. Anda juga dapat menyembunyikan reset action pada laporan yang tidak memiliki arti tanpa filter.
Indicator
$state = $tableQuery->state();
// [['name' => 'verified', 'label' => 'Email verification: Verified']]
$state['filterIndicators'];2
3
4
Indicator dibangun di server karena hanya filter yang mengetahui makna nilainya: 1 mungkin berarti “Verified”, bukan sekadar angka “1”. Override describe(mixed $value): string pada filter kustom untuk mengubah bagian kanan teks indicator; bagian kiri selalu menggunakan label filter.
Persistence
persistFiltersInSession() mengingat map filter sebagai satu kesatuan, bukan satu filter per key session. “Filter mana yang sedang aktif” adalah satu keputusan; menyimpan masing-masing filter secara terpisah akan membuat kondisi “filter dihapus” sulit dibedakan dari “filter belum pernah diset”. Session key dibangun dari panel id dan resource slug, bukan dari request. Lihat state tabel yang dipersistensikan.
Membuat filter kustom
Turunkan class dari PandaPanel\Tables\Filters\Filter dan implementasikan tiga method berikut.
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Filters;
use Illuminate\Database\Eloquent\Builder;
use PandaPanel\Tables\Enums\FilterType;
use PandaPanel\Tables\Filters\Filter;
final class MinimumTotalFilter extends Filter
{
public function type(): FilterType
{
return FilterType::Select;
}
/** Null makes the filter a no-op for this request. */
public function sanitize(mixed $value): ?int
{
return is_numeric($value) && (int) $value > 0 ? (int) $value : null;
}
protected function constrain(Builder $query, mixed $value): void
{
$query->where($this->getColumn(), '>=', $value);
}
/** @return array<string, mixed> */
protected function extraArray(): array
{
return ['options' => [
['value' => '100', 'label' => 'Over 100'],
['value' => '1000', 'label' => 'Over 1000'],
]];
}
}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
type() harus menggunakan salah satu case FilterType yang sudah tersedia karena frontend merender filter berdasarkan closed union. Jika Anda membutuhkan kontrol yang belum tersedia, gunakan FormFilter dengan satu field daripada menciptakan type yang tidak dikenali frontend.
Hal yang perlu diperhatikan
- Nilai yang ditolak menjadi no-op, bukan error.
?filters[status]=inventedtidak mempersempit query dan tidak muncul distate()['filters'], sehingga kontrol juga tidak terlihat aktif. query()menggantikan constraint sepenuhnya. Mulai dari operator, kolom, hingga penanganan null menjadi tanggung jawab closure Anda.- Filter yang hanya menggunakan base-query modifier tidak menerapkan constraint lain. Tambahkan
query()atau implementasikanconstrain()jika keduanya memang dibutuhkan. - Filter tidak pernah mempersempit lookup satu record. Filter bekerja di
TableQuery::paginate(), bukanResource::query(). Record yang tidak terlihat karena filter — termasuk karena base-query modifier — tetap dapat dibuka melalui URL jika lookup resource mengizinkannya. FormFilter::sanitize()menjalankan aturan validasi form. Jika satu rule gagal, seluruh filter dibuang, bukan hanya field yang bermasalah.- Dua filter dengan nama yang sama melempar
PanelSchemaException::duplicateFilters()pada setterfilters().
Lihat juga
- Dasar-dasar TableSchema
- Filter query builder
- Tab — scope pada query resource, bukan filter
- State tabel yang dipersistensikan
- Pencarian dan pengurutan
- Form dan schema — untuk
FormFilter - Soft delete — untuk
TrashedFilter - Referensi API tabel