Scoping Resource berdasarkan Tenant
Resource di dalam tenant-scoped panel menyebut relationship yang mengarah ke tenant, lalu setiap pembacaan resource tersebut otomatis dipersempit ke tenant yang sedang terikat — termasuk list, record lookup, action, bulk action, global search, dan export. Menentukan relationship adalah seluruh mekanisme opt-in; resource yang tidak menyebut relationship akan dibiarkan persis seperti sebelumnya.
Melakukan scope pada resource
<?php
declare(strict_types=1);
namespace App\Panels\App\Resources\Documents;
use App\Models\Document;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\FormSchema;
use PandaPanel\Resources\Resource;
use PandaPanel\Tables\Columns\TextColumn;
use PandaPanel\Tables\TableSchema;
final class DocumentResource extends Resource
{
protected static string $model = Document::class;
/** The relationship on Document that leads to the tenant. */
protected static ?string $tenantRelationship = 'workspace';
public static function table(TableSchema $table): TableSchema
{
return $table->columns([TextColumn::make('title')]);
}
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' => ListDocuments::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
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class Document extends Model
{
/** @return BelongsTo<Workspace, $this> */
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use PandaPanel\Tenancy\Tenancy;
Tenancy::for($acme, fn () => DocumentResource::query()->pluck('title')->all());
// ['Acme plan', 'Acme notes']
Tenancy::for($beta, fn () => DocumentResource::query()->pluck('title')->all());
// ['Beta secrets']2
3
4
5
6
7
API
| Member | Signature | Default |
|---|---|---|
$tenantRelationship | protected static ?string $tenantRelationship | null |
tenantRelationship() | public static function tenantRelationship(): ?string | mengembalikan static::$tenantRelationship |
applyTenantScope() | protected static function applyTenantScope(Builder $query): Builder | dipanggil oleh query() |
Override method-nya jika relationship bergantung pada kondisi yang tidak bisa dinyatakan hanya melalui property:
public static function tenantRelationship(): ?string
{
return panel()?->getId() === 'app' ? 'workspace' : null;
}2
3
4
Cara scope diterapkan
Resource::query() adalah satu-satunya jalur utama query resource:
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
Seluruh fitur framework melewati method ini — ListRecords, findRecord(), findRecords(), action endpoint, GlobalSearch, TableQuery, dan export. Jika scope terbukti benar di satu titik ini, maka scope yang sama berlaku di semua fitur tersebut. Itulah alasan framework tidak menyediakan hook scope terpisah untuk setiap halaman.
Implementasi scope-nya:
protected static function applyTenantScope(Builder $query): Builder
{
$panel = panel();
if ($panel === null || ! $panel->hasTenancy()) {
return $query;
}
$relationship = static::tenantRelationship();
if ($relationship === null) {
return $query;
}
// ... validation of the relationship, below
$tenant = Tenancy::require();
return $query->whereHas(
$relationship,
static fn (Builder $related): Builder => $related->whereKey($tenant->getKey()),
);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Ada tiga kondisi yang semuanya harus terpenuhi, diperiksa sesuai urutan kegagalan yang paling realistis:
| Kondisi | Jika tidak terpenuhi | Hasil |
|---|---|---|
| Panel menggunakan tenancy | tidak ada current panel, atau hasTenancy() false | query dikembalikan tanpa perubahan |
| Resource menyebut relationship tenant | tenantRelationship() bernilai null | query dikembalikan tanpa perubahan |
| Tenant sudah di-bind | tidak ada tenant terikat | PanelRegistrationException |
Kondisi ketiga menghasilkan exception, bukan melewati scope. Ini keputusan penting. Resource yang secara eksplisit menyatakan dirinya tenant-scoped tetapi kemudian berjalan tanpa scope hanya karena tenant tidak terikat akan mengembalikan record milik seluruh tenant — persis jenis kegagalan yang hendak dicegah dan berbahaya karena halaman tetap terlihat berfungsi normal.
Relationship yang dapat digunakan
Scope dibangun dengan whereHas, sehingga definisi relationship milik model-lah yang menentukan arti "record ini milik tenant tersebut", bukan nama column yang ditebak oleh framework.
| Relation | Didukung | Pola umum |
|---|---|---|
belongsTo | ya | Document → Workspace |
belongsToMany | ya | Document dibagikan ke beberapa workspace melalui pivot |
hasOneThrough | ya | Comment → Post → Workspace |
hasMany, morphTo, dan lainnya | apa pun yang dapat digunakan Eloquent dengan whereHas |
/** @return HasOneThrough<Workspace, Post, $this> */
public function workspace(): HasOneThrough
{
return $this->hasOneThrough(Workspace::class, Post::class, 'id', 'id', 'post_id', 'workspace_id');
}2
3
4
5
Related query dipersempit menggunakan whereKey($tenant->getKey()) — yaitu primary key model tenant, bukan PanelTenant::getTenantKey(). Tenant yang diidentifikasi dengan slug pada URL tetap di-join menggunakan id database-nya.
Kapan tidak perlu mendeklarasikan relationship
Relationship null tidak selalu berarti kelalaian. Ada dua kasus utama di mana kondisi tersebut memang benar:
- Database per tenant. Connection merupakan boundary isolasinya; tidak ada column tenant yang perlu di-scope, dan
whereHasmalah akan mencari tabel yang tidak tersedia. Lihat Satu Database per Tenant. - Tabel yang benar-benar global — misalnya plan, negara, atau feature flag — yang dibaca sama oleh seluruh tenant.
final class WorkspaceResource extends Resource
{
protected static string $model = Workspace::class;
// No $tenantRelationship: the tenant list itself is not per tenant.
}2
3
4
5
6
Tenancy::for($acme, fn () => WorkspaceResource::query()->pluck('name')->all());
// ['Acme', 'Beta']2
Melakukan scope secara manual
Jika relationship bukan satu-satunya aturan — misalnya ada flag "shared with everyone" atau Anda ingin menggunakan column langsung — override query() dan tetap panggil parent::query(). Jika tidak, narrowing bawaan panel akan hilang tanpa warning:
use Illuminate\Database\Eloquent\Builder;
use PandaPanel\Tenancy\Tenancy;
public static function query(): Builder
{
return parent::query()
->where(static fn (Builder $q) => $q
->where('workspace_id', Tenancy::key())
->orWhere('is_public', true));
}2
3
4
5
6
7
8
9
10
Tenancy::key() mengembalikan getTenantKey() jika model tenant mengimplementasikan PanelTenant. Jika nilai tersebut adalah slug sementara foreign key Anda menggunakan id, gunakan Tenancy::require()->getKey().
Hal yang tidak di-scope otomatis
Write operation. applyTenantScope() hanya mempersempit read. Tidak ada mekanisme yang otomatis menulis workspace_id saat create — framework memanggil FormSchema::dehydrate() lalu menyimpan model, dan tenant id yang dapat dikirim user berarti tenant id juga dapat dimanipulasi. Tetapkan ownership dari sisi server, di tempat yang tidak dapat disentuh request:
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Models\Document;
use PandaPanel\Tenancy\Tenancy;
final class DocumentObserver
{
public function creating(Document $document): void
{
$document->workspace_id ??= Tenancy::require()->getKey();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Karena semua read di-scope, record yang dibuat tanpa owner akan langsung tidak terlihat. Itu adalah bug yang idealnya muncul pada test pertama, bukan setelah masuk production.
Relation manager dan nested resource. Query keduanya dimulai dari relation milik parent record, dan parent record itu sendiri sudah ditemukan melalui query() yang di-scope. Tenant scope pada child resource tetap diterapkan di atasnya jika child juga menyebut relationship tenant.
Select option dan lookup form lainnya. Select yang mengambil option dari model akan membaca model tersebut, bukan resource. Lakukan scope di query milik field itu sendiri.
Error yang ditangani framework
Dua kesalahan berikut ditangkap dengan pesan yang menyebut resource, model, dan method terkait, sehingga Anda tidak harus menebak error internal Eloquent:
protected static ?string $tenantRelationship = 'nothing_like_this';
[DocumentResource]scopes to the tenant through[nothing_like_this], which[App\Models\Document]does not have. Name a relationship that exists, or overridequery()and scope it yourself.
protected static ?string $tenantRelationship = 'getTable';
[DocumentResource]scopes by the tenant relationship[getTable], and[App\Models\Document::getTable()]exists but does not return an Eloquent relationship. A scope or an accessor cannot be traversed to a tenant — name abelongsTo,belongsToManyorhasOneThrough, or overridequery()and scope it yourself.
Keduanya merupakan PandaPanel\Exceptions\PanelRegistrationException. Pemeriksaan kedua diperlukan karena method_exists() yang lolos tidak berarti method tersebut adalah relationship. Scope atau accessor dapat melewati pemeriksaan awal lalu gagal jauh di dalam whereHas dengan error seperti "Call to a member function getRelated() on null", yang tidak memberi tahu resource atau property mana yang salah.
Kegagalan ketiga adalah yang paling penting:
DocumentResource::query()->get(); // outside a request, nothing boundThis panel is tenant-scoped, but no tenant is bound to this request. Routes registered by the panel resolve one through
ResolveTenant; a route registered by hand has to include that middleware, and console or queue work has to enter a tenant withTenancy::for().
Mengujinya
use PandaPanel\Exceptions\PanelRegistrationException;
use PandaPanel\Tenancy\Tenancy;
it('shows one tenant\'s records and not the other\'s', function (): void {
$acme = Tenancy::for($this->acme, fn (): array => DocumentResource::query()
->pluck('title')
->all());
$beta = Tenancy::for($this->beta, fn (): array => DocumentResource::query()
->pluck('title')
->all());
expect($acme)->toBe(['Acme plan', 'Acme notes'])
->and($beta)->toBe(['Beta secrets']);
});
it('raises rather than running unscoped when no tenant is bound', function (): void {
expect(fn () => DocumentResource::query()->get())
->toThrow(PanelRegistrationException::class);
});
it('scopes nothing in a panel that declared no tenancy', function (): void {
app(PanelManager::class)->setCurrentPanel(null);
expect(DocumentResource::query()->count())->toBe(3);
});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
Catatan
- Tenancy adalah properti panel. Class resource yang sama dapat didaftarkan pada tenant panel dan central admin panel; pada tenant panel ia di-scope, sedangkan pada central panel ia tetap melihat keseluruhan data tanpa perlu mengubah class.
- Scope menggunakan subquery
whereHas. Pastikan foreign key memiliki index. Pada tabel besar dengan relationship tenantbelongsToMany, exists subquery adalah tempat pertama yang perlu diperiksa jika list lambat. findRecord()hanya mengangkatSoftDeletingScope, tidak tenant scope. Tenant scope tetap berlaku pada trashed record, sehingga restore action tidak dapat mencapai row milik tenant lain.- Record di luar tenant menghasilkan 404, bukan sekadar row yang terfilter. Record lookup melewati query yang sama, sehingga id milik tenant lain yang diketik manual tetap tidak dapat di-resolve.
parent::query()wajib dipanggil saat override. Melewatkannya akan sekaligus membuang tenant scope, narrowing per-panel, dan eager load.- Tidak ada automatic scope berdasarkan nama column
tenant_id. Framework tidak menerapkan convention terhadap nama column dan tidak mendaftarkan global scope otomatis; relationship yang Anda deklarasikan adalah mekanisme utamanya.