Query Resource
Resource::query() adalah satu-satunya entry point untuk setiap record yang dapat dijangkau sebuah Resource. Override method ini sekali, lalu scope-nya otomatis berlaku pada list, record page, Action, bulk operation, dan global search. Halaman ini membahas method tersebut, deklarasi yang memengaruhinya, dan layer lain yang dapat mempersempit query lebih jauh.
Override minimal
use Illuminate\Database\Eloquent\Builder;
public static function query(): Builder
{
return parent::query()->where('team_id', currentTeamId());
}2
3
4
5
6
Itulah seluruh mekanismenya. Tidak ada bagian lain yang perlu diubah: record dengan team_id milik team lain menghasilkan 404 pada view page, edit page, action endpoint, dan command palette. Record tersebut bukan hanya hilang dari list tetapi masih bisa dijangkau lewat URL.
Selalu panggil parent::query(). Base method menerapkan eager load Resource, nested-resource scope, tenant scope, dan penyempitan query milik Panel. Memulai dari Model::query() secara langsung akan membuang keempatnya tanpa warning.
Yang dilakukan base method
public static function query(): Builder
{
$query = static::isNested()
? static::parentRelation()->getQuery()->with(static::$with)
: static::getModel()::query()->with(static::$with);
$query = static::applyTenantScope($query);
return static::configurationIn(panel())?->applyQuery($query) ?? $query;
}2
3
4
5
6
7
8
9
10
Ada tiga tahap, berurutan: menentukan starting builder — query model biasa atau relation milik parent record untuk nested resource — menerapkan tenant scope, lalu menerapkan penyempitan yang dikonfigurasi khusus untuk Panel saat ini.
Semua jalur yang membaca melalui query()
| Surface | Jalur |
|---|---|
| Index | ListRecords::render() dimulai dari query() lalu menyerahkannya ke layer table |
| View, edit, dan custom record page | resolveRecord() → recordQuery() → query() |
| Record, cell, dan infolist action | findRecord() → recordQuery() → query() |
| Bulk action | findRecords() → recordQuery() → query() |
| Global search | globalSearchQuery(), yang mengembalikan query() |
| File upload yang terikat ke record | Langsung menggunakan query() |
| Export | Menggunakan table query yang sama dengan list |
recordQuery() adalah query() dengan tepat satu perbedaan: SoftDeletingScope dilepas untuk Resource yang mendeklarasikan $softDeletes. Dengan begitu trashed record dapat dibuka dan direstore, sementara tenant, module, permission, dan scope lainnya tetap berlaku. Lihat Soft deletes.
Eager loading
/** @var list<string> */
protected static array $with = ['author', 'tags'];2
Deklarasi ini diterapkan pada setiap query yang dibangun Resource, sehingga proses serialization column tidak pernah memicu lazy load satu kali per row. Ini bukan optimasi yang sebaiknya ditunda. Jika Model::shouldBeStrict() aktif di luar production, column yang membaca relation yang belum di-load akan gagal secara eksplisit; tanpa strict mode, bug yang sama diam-diam menambah satu query untuk setiap record.
Serialization list page diharapkan menjalankan jumlah query yang tetap, berapa pun jumlah row pada page. Test package sendiri memastikan lima row dan tiga puluh lima row menghasilkan query count yang sama.
Untuk relation yang dibutuhkan oleh satu column pada seluruh Panel, gunakan $with. Untuk eager loading yang conditional, tambahkan pada override:
use Illuminate\Database\Eloquent\Builder;
public static function query(): Builder
{
return parent::query()->withCount('comments');
}2
3
4
5
6
Mempersempit query per Panel
Resource yang diregistrasikan pada dua Panel dapat memiliki arti yang lebih sempit pada salah satu Panel tanpa perlu subclass:
use Illuminate\Database\Eloquent\Builder;
use PandaPanel\Resources\ResourceConfiguration;
$panel->resources([
ResourceConfiguration::for(UserResource::class)
->slug('people')
->modifyQueryUsing(static fn (Builder $query): Builder => $query->where('is_admin', false)),
]);2
3
4
5
6
7
8
modifyQueryUsing() diterapkan terakhir oleh query() sendiri, sehingga constraint tersebut dikombinasikan dengan semua scope yang sudah dibuat Resource. Jaminan 404 tetap sama: dari Panel tersebut, administrator record tidak dapat dibuka, diedit, dihapus, dipilih oleh bulk action, atau ditemukan global search. Lihat Per-panel configuration.
Mempersempit query per tab
use Illuminate\Database\Eloquent\Builder;
use PandaPanel\Tables\Tab;
/**
* @return array<string, Tab>
*/
public function tabs(): array
{
return [
'all' => Tab::make('all'),
'drafts' => Tab::make('drafts')
->query(static fn (Builder $query): Builder => $query->whereNull('published_at')),
];
}2
3
4
5
6
7
8
9
10
11
12
13
14
Tab menerima Resource::query() lalu mengembalikannya dalam bentuk yang lebih sempit. Tab adalah presentation filter, bukan security boundary. Record page tidak mengetahui tab mana yang sedang aktif, sehingga record yang disembunyikan tab tetap dapat dijangkau lewat URL. Gunakan query() atau modifyQueryUsing() untuk data yang memang tidak boleh dapat dijangkau.
Tenant scoping
Resource di dalam tenant-scoped Panel melakukan opt-in dengan menyebut relationship yang mengarah ke tenant:
final class DocumentResource extends Resource
{
protected static string $model = Document::class;
protected static ?string $tenantRelationship = 'workspace';
}2
3
4
5
6
public static function tenantRelationship(): ?string; // reads $tenantRelationshipMendeklarasikan relationship adalah seluruh proses opt-in. Resource yang tidak menyebut relationship tidak di-scope tenant. Behavior ini benar untuk dua kasus nyata: arsitektur database-per-tenant ketika connection sudah menjadi boundary, dan table yang memang global seperti plan, country, atau feature flag yang dibaca semua tenant.
Scope dibangun menggunakan whereHas, sehingga belongsTo, belongsToMany, dan hasOneThrough dapat digunakan. Definisi relation itu sendiri yang menentukan arti "record ini milik tenant tersebut". Tiga kondisi diperiksa dengan urutan berikut:
- Panel harus memiliki tenancy. Jika tidak, query dikembalikan tanpa perubahan.
- Resource harus mendeklarasikan relationship tenant. Jika tidak, query dikembalikan tanpa perubahan.
- Tenant harus sudah di-bind. Jika tidak,
PandaPanel\Tenancy\Tenancy::require()melempar exception.
Kondisi ketiga sengaja menghasilkan exception, bukan melewati scope. Resource yang menyatakan dirinya tenant-scoped tetapi kemudian berjalan tanpa tenant akan mengembalikan record milik seluruh tenant — persis kegagalan yang ingin dicegah mekanisme ini, dan masalah tersebut dapat terlihat seperti page normal yang "berfungsi".
Console command dan queued job yang secara sah berjalan di luar request harus masuk ke tenant secara eksplisit:
use PandaPanel\Tenancy\Tenancy;
Tenancy::for($workspace, static function (): void {
DocumentResource::query()->each(/* ... */);
});2
3
4
5
Dua kesalahan registration juga dibuat gagal secara eksplisit: $tenantRelationship menunjuk method yang tidak ada pada model, atau menunjuk method yang ada tetapi bukan Relation seperti scope atau accessor. Keduanya melempar PanelRegistrationException yang menyebut Resource, model, dan property yang bermasalah.
Layer table di atas query Resource
ListRecords tidak menerapkan search, sorting, filter, atau pagination sendiri. Page menyerahkan query Resource ke PandaPanel\Tables\TableQuery, yang membaca URL lalu hanya menerapkan behavior yang dideklarasikan schema: column yang tidak searchable() tidak ikut search, column yang tidak sortable() tidak dapat di-sort, filter name yang tidak dikenal diabaikan, dan perPage dibatasi ke option yang dideklarasikan. Tidak ada value request yang digunakan sebagai nama column secara langsung.
Builder yang sama digunakan untuk page dan summary. Karena itu aggregate selalu menggambarkan query yang sama dengan data yang sedang dipaginasi user. Lihat Tables dan Filters.
Global search
use Illuminate\Database\Eloquent\Builder;
public static function globalSearchQuery(): Builder
{
return static::query();
}2
3
4
5
6
Search dimulai dari query yang sama dengan seluruh jalur lain, sehingga Resource scope mempersempit command palette persis seperti mempersempit list. Override method ini bila search memang perlu starting point yang berbeda — misalnya hanya published record — tanpa mengubah jangkauan Resource page. Lihat Global search.
Catatan penting
- Resource yang meng-override
query()tetapi lupa memanggilparent::query()kehilangan per-Panel narrowing, tenant scope, nested parent scope, dan$withsekaligus, semuanya tanpa warning. Gejalanya justru terlihat seperti page normal yang menampilkan terlalu banyak data. - Scope menghasilkan 404, bukan sekadar filtered row. Inilah yang membuat boundary dapat dibuktikan: guessed ID ditolak oleh rule yang sama dengan rule yang menyembunyikan link.
- Jangan menerapkan scope keamanan pada page.
wherepada page hanya melindungi page tersebut; action endpoint tidak melewati page. Record dapat hilang dari list tetapi tetap dapat dihapus. - Jangan menerapkan scope keamanan pada table schema. Layer table berjalan setelah query Resource dan hanya mendeskripsikan presentation; record page tidak melihat constraint tersebut.
query()bersifat static dan dijalankan setiap kali dipanggil. Method tidak di-memoize. Aman dipanggil dua kali, tetapi override yang melakukan pekerjaan mahal juga akan menjalankan pekerjaan tersebut dua kali.- Di luar Panel tidak ada per-Panel configuration yang diterapkan.
configurationIn(null)menghasilkannull, sehinggaquery()dari console command menggunakan query milik Resource sendiri ditambah tenancy jika memang ada context tenant.