Tenancy
Dokumentasi ini merupakan referensi untuk seluruh class yang membentuk mekanisme tenancy di PandaBear:
PandaPanel\Tenancy\Tenancy;- dua contract yang diimplementasikan application;
- method pada
Panelyang mengaktifkan tenancy; - serta property dan method pada
Resourceyang melakukan tenant scoping terhadap query.
Gunakan halaman ini ketika Anda sudah memahami konsep tenancy dan membutuhkan detail seperti:
- signature method;
- return type;
- failure mode;
- lifecycle;
- atau behavior pasti dari sebuah call.
Untuk pembahasan konsep seperti:
- apa arti tenant di PandaBear;
- mengapa identifikasi Tenant menjadi tanggung jawab application;
lihat:
Ini Bukan Implementasi Multi-Tenancy
Hal ini sangat penting.
PandaBear Tenancy bukan framework multi-tenancy lengkap.
Mekanisme ini tidak:
- membuat database per Tenant;
- mengganti database connection;
- melakukan cache partitioning;
- membaca subdomain;
- membuat schema database;
- atau menentukan bagaimana tenant diisolasi secara infrastruktur.
Tenancy PandaBear hanya menjawab satu pertanyaan per request:
Request ini sedang bekerja untuk Tenant yang mana?
Setelah Tenant ditemukan, jawaban tersebut disimpan pada request-scoped context sehingga seluruh bagian framework dapat membacanya.
Secara konsep:
HTTP Request
↓
Resolve Tenant
↓
Current Tenant
↓
PanelContext
↓
Resource / Page / Widget / Action2
3
4
5
6
7
8
9
Contoh Minimal yang Berfungsi
Ada tiga bagian yang semuanya diperlukan:
Panel menentukan:
- apa Tenant Model-nya;
- bagaimana Tenant ditemukan dari request.
User Model menentukan:
- Tenant mana yang boleh ditawarkan;
- Tenant mana yang benar-benar boleh dimasuki.
Resource menentukan:
- relation mana yang menghubungkan record dengan Tenant.
1. Panel Mendeklarasikan Tenant
<?php
declare(strict_types=1);
namespace App\Panels\App;
use App\Models\Workspace;
use Illuminate\Http\Request;
use PandaPanel\Core\Panel;
use PandaPanel\Core\PanelProvider;
final class AppPanelProvider
extends PanelProvider
{
public function panel(
Panel $panel
): Panel {
return $panel
->path('app')
->auth()
->tenant(
Workspace::class,
static fn (
Request $request
): ?Workspace =>
Workspace::query()
->find(
$request
->query(
'workspace'
)
),
)
->tenantUrlUsing(
static fn (
Workspace $workspace,
Panel $panel
): string =>
'/'
. $panel->getPath()
. '/documents?workspace='
. $workspace
->getKey(),
);
}
}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
2. User Menentukan Tenant yang Boleh Diakses
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use PandaPanel\Contracts\HasPanelTenants;
use PandaPanel\Core\Panel;
final class User
extends Authenticatable
implements HasPanelTenants
{
/**
* @return BelongsToMany<Workspace, $this>
*/
public function workspaces():
BelongsToMany
{
return $this
->belongsToMany(
Workspace::class
);
}
/**
* @return Collection<int, Model>
*/
public function getPanelTenants(
Panel $panel
): Collection {
return $this
->workspaces()
->orderBy('id')
->get();
}
public function canAccessPanelTenant(
Model $tenant,
Panel $panel
): bool {
return $this
->workspaces()
->whereKey(
$tenant->getKey()
)
->exists();
}
}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
3. Resource Menentukan Tenant Relationship
<?php
declare(strict_types=1);
namespace App\Panels\App\Resources\Documents;
use App\Models\Document;
use PandaPanel\Resources\Resource;
final class DocumentResource
extends Resource
{
protected static string $model =
Document::class;
/**
* Relationship pada Document
* yang mengarah ke Tenant.
*/
protected static ?string
$tenantRelationship =
'workspace';
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Sekarang request:
GET /app/documents?workspace=1akan:
Resolve workspace 1
↓
Check user boleh masuk?
↓
Bind workspace sebagai current Tenant
↓
DocumentResource::query()
↓
Scope documents ke workspace tersebut2
3
4
5
6
7
8
9
Semua read terhadap DocumentResource menggunakan Tenant yang sama.
PandaPanel\Tenancy\Tenancy
Tenancy merupakan:
final class Tenancyyang berisi static methods.
Namun current Tenant tidak disimpan pada static property.
Tenant disimpan di:
PandaPanel\Support\PanelContextyang diregistrasikan sebagai:
scoped()pada Laravel container.
Artinya current Tenant hidup sepanjang satu request saja.
Ini mencegah Tenant bocor antara:
- dua HTTP request;
- dua test;
- dua job;
- atau dua request dalam satu Octane worker.
API Tenancy
| Method | Return | Kegunaan |
|---|---|---|
bind(Model $tenant) | void | Bind Tenant untuk request saat ini |
current() | ?Model | Current Tenant atau null |
require() | Model | Current Tenant atau throw exception |
key() | int|string|null | Identifier current Tenant |
keyOf(Model $tenant) | int|string | Identifier dari Tenant tertentu |
nameOf(Model $tenant) | string | Nama Tenant untuk UI |
describe(Model $tenant) | array{key, name} | Representasi Tenant untuk frontend |
availableTo(?Authenticatable $user, Panel $panel) | list<Model> | Tenant yang boleh ditawarkan kepada user |
allows(?Authenticatable $user, Model $tenant, Panel $panel) | bool | Apakah user boleh memasuki Tenant |
for(Model $tenant, callable $callback) | mixed | Menjalankan callback dalam context Tenant tertentu |
bind()
Signature:
public static function bind(
Model $tenant
): void;2
3
Method ini menyimpan Tenant ke request-scoped context dengan key:
panel.tenantContoh:
use PandaPanel\Tenancy\Tenancy;
Tenancy::bind(
$workspace
);2
3
4
5
Siapa yang Seharusnya Memanggil bind()
Dalam penggunaan normal hanya:
PandaPanel\Http\Middleware\ResolveTenantdan test framework yang seharusnya memanggil method ini secara langsung.
Application code sebaiknya tidak menggunakan bind() sebagai entry point.
Mengapa?
Karena jika Tenant baru di-bind di tengah request:
Query A
→ belum scoped
Tenancy::bind()
Query B
→ scoped2
3
4
5
6
7
maka satu request memiliki dua tenant boundary berbeda.
Untuk application code yang perlu masuk sementara ke Tenant tertentu, gunakan:
Tenancy::for()karena method tersebut memulihkan Tenant sebelumnya setelah callback selesai.
current()
Signature:
public static function current():
?Model;2
Mengembalikan:
Modeljika Tenant telah di-bind.
Mengembalikan:
nulljika:
- Panel tidak menggunakan tenancy;
- request tidak melewati
ResolveTenant; - dipanggil di luar request;
- value pada context bukan Eloquent Model.
Contoh:
use PandaPanel\Tenancy\Tenancy;
$workspace =
Tenancy::current();
if (
$workspace !== null
) {
// ...
}2
3
4
5
6
7
8
9
10
require()
Signature:
/**
* @throws
* \PandaPanel\Exceptions\PanelRegistrationException
*/
public static function require():
Model;2
3
4
5
6
require() dapat dianggap sebagai:
current()
+
Tenant wajib ada2
3
Jika Tenant tidak tersedia:
PanelRegistrationExceptiondilempar.
Method ini digunakan oleh:
Resource::applyTenantScope()Hal ini disengaja.
Resource yang sudah menyatakan dirinya tenant-scoped tetapi tidak memiliki Tenant tidak boleh fallback ke query tanpa scope.
Contoh:
use PandaPanel\Tenancy\Tenancy;
$tenant =
Tenancy::require();
Invoice::query()
->where(
'workspace_id',
$tenant->getKey()
)
->sum('total');2
3
4
5
6
7
8
9
10
11
Mengapa Missing Tenant Harus Throw
Bayangkan:
DocumentResource
$tenantRelationship = workspace2
tetapi current Tenant tidak tersedia.
Behavior yang berbahaya:
tenant tidak ada
↓
skip scope
↓
SELECT * FROM documents
↓
semua Tenant terlihat2
3
4
5
6
7
PandaBear memilih:
tenant tidak ada
↓
exception2
3
daripada silent data leak.
key()
Signature:
public static function key():
int|string|null;2
Mengembalikan identifying value dari current Tenant.
Jika tidak ada Tenant:
nullContoh:
use PandaPanel\Tenancy\Tenancy;
Document::query()
->where(
'workspace_id',
Tenancy::key()
)
->count();2
3
4
5
6
7
8
Untuk sebagian besar query sederhana, method ini sudah cukup.
keyOf()
Signature:
public static function keyOf(
Model $tenant
): int|string;2
3
Jika Tenant Model mengimplementasikan:
PanelTenantframework menggunakan:
$tenant->getTenantKey()Jika tidak:
$tenant->getKey()digunakan.
Jika primary key bukan int atau string, value di-cast ke string.
Contoh:
use PandaPanel\Tenancy\Tenancy;
Tenancy::keyOf(
$workspace
);
// 412
3
4
5
6
Dengan PanelTenant yang menggunakan slug:
acmenameOf()
Signature:
public static function nameOf(
Model $tenant
): string;2
3
Prioritas:
PanelTenant::getTenantName()
↓
non-empty 'name' attribute
↓
Tenant key sebagai string2
3
4
5
Contoh:
use PandaPanel\Tenancy\Tenancy;
Tenancy::nameOf(
$workspace
);
// 'Acme'2
3
4
5
6
Framework memilih fallback ke key daripada empty string.
Alasannya Tenant Switcher dengan row kosong tidak dapat digunakan dengan baik.
describe()
Signature:
/**
* @return array{
* key: int|string,
* name: string
* }
*/
public static function describe(
Model $tenant
): array;2
3
4
5
6
7
8
9
Contoh:
use PandaPanel\Tenancy\Tenancy;
Tenancy::describe(
$workspace
);2
3
4
5
Hasil:
[
'key' => 41,
'name' => 'Acme',
]2
3
4
SharePanelData nantinya menambahkan:
url
current2
sebelum payload mencapai Vue.
availableTo()
Signature:
/**
* @return list<Model>
*/
public static function availableTo(
?Authenticatable $user,
Panel $panel
): array;2
3
4
5
6
7
Method ini membangun daftar Tenant untuk Tenant Switcher.
Sumbernya:
HasPanelTenants::getPanelTenants()Jika User Model tidak mengimplementasikan:
HasPanelTenantshasilnya:
[]Framework menganggap user tidak memiliki Tenant.
Contoh:
use PandaPanel\Tenancy\Tenancy;
$tenants =
Tenancy::availableTo(
$request->user(),
panel('app')
);2
3
4
5
6
7
allows()
Signature:
public static function allows(
?Authenticatable $user,
Model $tenant,
Panel $panel
): bool;2
3
4
5
Method ini memanggil:
HasPanelTenants::canAccessPanelTenant()secara langsung.
Ia tidak mencari Tenant di dalam hasil availableTo().
Hal tersebut penting untuk security.
Daftar Tenant dari availableTo() dibuat untuk kebutuhan UI.
Daftar tersebut suatu saat dapat:
- dipaginate;
- dibatasi;
- disortir;
- hanya menampilkan Tenant terbaru.
Authorization tidak boleh berubah karena keputusan UI.
Prinsipnya:
availableTo()
→ apa yang ditawarkan
allows()
→ apa yang diizinkan2
3
4
5
Jika:
- user null;
- User Model tidak mengimplementasikan
HasPanelTenants;
hasilnya:
falseContoh:
use PandaPanel\Tenancy\Tenancy;
abort_unless(
Tenancy::allows(
$request->user(),
$workspace,
panel('app')
),
403
);2
3
4
5
6
7
8
9
10
for()
Signature:
/**
* @template TReturn
*
* @param callable(): TReturn $callback
* @return TReturn
*/
public static function for(
Model $tenant,
callable $callback
): mixed;2
3
4
5
6
7
8
9
10
Flow:
Simpan Tenant sebelumnya
↓
Bind Tenant baru
↓
Jalankan callback
↓
finally
↓
Pulihkan Tenant sebelumnya2
3
4
5
6
7
8
9
Restoration tetap dilakukan jika callback melempar exception.
Itulah tujuan utama method ini.
Kapan Menggunakan for()
Gunakan untuk pekerjaan yang secara sah melewati Tenant boundary.
Contohnya:
- console command yang looping Tenant;
- queued job yang harus kembali ke Tenant asal;
- test yang membandingkan visibility antar-Tenant.
Contoh:
use PandaPanel\Tenancy\Tenancy;
foreach (
Workspace::query()
->cursor()
as $workspace
) {
Tenancy::for(
$workspace,
function () use (
$workspace
): void {
$this->info(
$workspace->name
. ': '
. DocumentResource::query()
->count()
);
}
);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Jika sebelum callback tidak ada Tenant yang di-bind:
setelah callback
→ tidak ada Tenant lagi2
PandaPanel\Contracts\PanelTenant
Contract ini optional pada Tenant Model.
Contract hanya memiliki dua method karena seluruh business meaning dari Tenant tetap menjadi milik application.
Panel hanya membutuhkan dua informasi:
- apa identifier Tenant;
- apa nama Tenant yang ditampilkan.
Contract:
interface PanelTenant
{
public function getTenantKey():
int|string;
public function getTenantName():
string;
}2
3
4
5
6
7
8
Contoh:
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Contracts\PanelTenant;
final class Workspace
extends Model
implements PanelTenant
{
public function getTenantKey():
int|string
{
return (string)
$this->slug;
}
public function getTenantName():
string
{
return (string)
$this->name;
}
}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
Dengan configuration tersebut Tenant dapat direpresentasikan sebagai:
acmedi URL.
Misalnya:
/app?workspace=acmeTanpa PanelTenant
Jika Tenant Model tidak mengimplementasikan contract:
Tenancy::keyOf()fallback ke:
getKey()sedangkan:
Tenancy::nameOf()fallback ke attribute:
namekemudian ke key.
Lihat:
PandaPanel\Contracts\HasPanelTenants
Contract ini wajib pada User Model untuk Panel yang mendeklarasikan:
tenant()Contract:
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Core\Panel;
interface HasPanelTenants
{
/**
* @return Collection<int, Model>
*/
public function getPanelTenants(
Panel $panel
): Collection;
public function canAccessPanelTenant(
Model $tenant,
Panel $panel
): bool;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Dua Pertanyaan yang Berbeda
| Method | Dipanggil oleh | Kapan |
|---|---|---|
getPanelTenants() | Tenancy::availableTo() | Ketika membuat daftar Tenant Switcher |
canAccessPanelTenant() | Tenancy::allows() melalui ResolveTenant | Setiap request sebelum query dijalankan |
Argument:
Panel $paneldiberikan agar User Model dapat memberikan jawaban berbeda untuk Panel yang berbeda.
Contohnya:
Admin Panel
→ boleh mengakses semua Workspace
Customer Panel
→ hanya Workspace sendiri2
3
4
5
User Tanpa HasPanelTenants
Jika tenant-scoped Panel digunakan tetapi User Model tidak mengimplementasikan interface ini:
request
→ 4032
Ini merupakan failure mode yang disengaja.
Framework memilih:
denydaripada menebak akses.
Lihat:
PandaPanel\Core\Panel
Tenancy API pada Panel:
| Method | Signature |
|---|---|
tenant() | tenant(string $model, Closure $resolver): self |
tenantUrlUsing() | tenantUrlUsing(Closure $url): self |
getTenantUrl() | getTenantUrl(Model $tenant): ?string |
hasTenancy() | hasTenancy(): bool |
getTenantModel() | getTenantModel(): ?string |
resolveTenant() | resolveTenant(Request $request, ?Authenticatable $user): ?Model |
tenant()
Signature:
/**
* @param class-string<Model> $model
* @param Closure(
* Request,
* ?Authenticatable
* ): ?Model $resolver
*/
public function tenant(
string $model,
Closure $resolver
): self;2
3
4
5
6
7
8
9
10
11
Method ini menyatakan bahwa Panel merupakan tenant-scoped Panel.
Empat hal terjadi.
Dan hanya empat hal tersebut:
ResolveTenantditambahkan ke route group Panel;Tenancy::current()tersedia sepanjang request;- Resource yang memiliki
tenantRelationship()otomatis di-scope; - daftar Tenant dibagikan ke frontend.
tenant() tidak otomatis mengubah database connection.
Tenant Resolver
Resolver menerima:
Request $requestdan:
?Authenticatable $userlalu mengembalikan:
?ModelResolver wajib diberikan.
Tidak ada default resolver karena setiap default yang mungkin benar untuk satu arsitektur bisa menjadi data leak untuk arsitektur lain.
Contoh Database per Tenant
Jika menggunakan package seperti stancl/tenancy yang sudah mengganti connection:
use App\Models\Tenant;
->tenant(
Tenant::class,
static fn () =>
tenant()
)2
3
4
5
6
7
PandaBear hanya membaca current Tenant.
Database switching tetap dilakukan oleh package tenancy tersebut.
Contoh Single Database dengan Path Segment
use App\Models\Team;
use Illuminate\Http\Request;
->tenant(
Team::class,
static fn (
Request $request
) =>
Team::query()
->where(
'slug',
$request
->route('team')
)
->first()
)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Contoh Satu Tenant per User
use App\Models\Workspace;
->tenant(
Workspace::class,
static fn (
$request,
$user
) =>
$user?->workspace
)2
3
4
5
6
7
8
9
10
11
Tidak ada Tenant identifier di URL.
tenantUrlUsing()
Signature:
/**
* @param Closure(
* Model,
* self
* ): string $url
*/
public function tenantUrlUsing(
Closure $url
): self;2
3
4
5
6
7
8
9
Resolver menjawab:
URL ini Tenant mana?
Sedangkan tenantUrlUsing() menjawab kebalikannya:
Tenant ini URL-nya apa?
Hanya application yang dapat mengetahui bagaimana resolver dibalik.
Contoh Subdomain Tenant
use PandaPanel\Core\Panel;
->tenantUrlUsing(
static fn (
Team $team
): string =>
"https://{$team->slug}.example.com/app"
)2
3
4
5
6
7
8
Contoh Path Tenant
->tenantUrlUsing(
static fn (
Team $team,
Panel $panel
): string =>
"/{$panel->getPath()}/{$team->slug}"
)2
3
4
5
6
7
Jika tenantUrlUsing() Tidak Ada
Tenant Switcher tidak dirender.
Alasannya:
Switcher menampilkan Tenant
↓
user klik
↓
tidak ada destination2
3
4
5
lebih buruk daripada tidak menampilkan Switcher sama sekali.
Lihat:
getTenantUrl()
Signature:
public function getTenantUrl(
Model $tenant
): ?string;2
3
Mengembalikan:
nulljika Panel belum menentukan:
tenantUrlUsing()Contoh:
panel('app')
->getTenantUrl(
$workspace
);
// '/app/documents?workspace=41'2
3
4
5
6
hasTenancy()
Signature:
public function hasTenancy():
bool;2
Digunakan framework untuk mengetahui apakah Panel menggunakan Tenant.
Contohnya oleh:
PanelRouteRegistraruntuk menentukan apakah ResolveTenant perlu ditambahkan.
Juga digunakan oleh:
Resource::applyTenantScope()getTenantModel()
Signature:
/**
* @return class-string<Model>|null
*/
public function getTenantModel():
?string;2
3
4
5
Contoh:
$panel =
panel('app');
$panel->hasTenancy();
// true
$panel->getTenantModel();
// 'App\Models\Workspace'2
3
4
5
6
7
8
resolveTenant()
Signature:
public function resolveTenant(
Request $request,
?Authenticatable $user
): ?Model;2
3
4
Method menjalankan resolver yang diberikan pada:
tenant()Method ini digunakan oleh:
ResolveTenantdan bukan API umum yang perlu dipanggil dari berbagai tempat.
Contoh:
$tenant =
panel('app')
->resolveTenant(
request(),
request()->user()
);2
3
4
5
6
Resolver Harus Mengembalikan Model yang Benar
Jika Panel mendeklarasikan:
Workspace::classtetapi resolver secara tidak sengaja mengembalikan:
Userframework menganggap hasilnya:
nullatau:
Tenant tidak ditemukanIni lebih aman daripada menggunakan object salah sebagai Tenant lalu membuat query terlihat seolah-olah berhasil di-scope.
PandaPanel\Resources\Resource
Tenant configuration pada Resource:
/**
* Relationship yang mengarah
* ke Tenant.
*
* null berarti tenancy tidak
* diterapkan pada Resource.
*/
protected static ?string
$tenantRelationship =
null;
public static function tenantRelationship():
?string;
/**
* @param Builder<covariant Model> $query
* @return Builder<covariant Model>
*/
protected static function applyTenantScope(
Builder $query
): Builder;2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Resource Tenancy adalah Opt-In
Resource::query() memanggil:
applyTenantScope()untuk setiap read:
- index;
- view;
- edit;
- delete;
- bulk;
- Action lookup;
- Global Search.
Karena itu cukup menentukan relationship:
protected static ?string
$tenantRelationship =
'workspace';2
3
Contoh:
<?php
declare(strict_types=1);
namespace App\Panels\App\Resources\Documents;
use App\Models\Document;
use PandaPanel\Resources\Resource;
final class DocumentResource
extends Resource
{
protected static string $model =
Document::class;
protected static ?string
$tenantRelationship =
'workspace';
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Dynamic Tenant Relationship
Jika relationship bergantung pada configuration, override:
tenantRelationship()Contoh:
public static function tenantRelationship():
?string
{
return config(
'app.single_database'
)
? 'workspace'
: null;
}2
3
4
5
6
7
8
9
Urutan Pemeriksaan Tenant Scope
Tiga kondisi diperiksa:
| Kondisi | Jika False |
|---|---|
panel()?->hasTenancy() | Query dikembalikan tanpa perubahan |
static::tenantRelationship() !== null | Query dikembalikan tanpa perubahan |
| Tenant sudah di-bind | PanelRegistrationException dari Tenancy::require() |
Kondisi terakhir berbeda.
Jika Resource sudah menyatakan dirinya tenant-scoped tetapi Tenant tidak di-bind:
throwbukan:
skipkarena skip dapat membuat seluruh data Tenant terlihat.
Scope Menggunakan whereHas()
Secara konsep:
$query->whereHas(
'workspace',
fn (
Builder $related
) =>
$related->whereKey(
$tenant->getKey()
)
);2
3
4
5
6
7
8
9
10
Karena menggunakan relationship, beberapa tipe relation dapat bekerja.
Contohnya:
belongsTo;belongsToMany;hasOneThrough.
Definition relationship menentukan arti:
Record ini termasuk Tenant tersebut melalui jalur apa?
Resource Tanpa Tenant Relationship
Resource yang memiliki:
tenantRelationship() === nulltidak di-scope oleh PandaBear.
Ini benar untuk dua kasus umum.
Database per Tenant
Database connection sudah menjadi tenant boundary.
Tidak diperlukan:
WHERE tenant_id = ...tambahan.
Global Table
Beberapa table memang global.
Contohnya:
- countries;
- subscription plans;
- currencies;
- feature definitions.
Semua Tenant membaca data yang sama.
Lihat:
PandaPanel\Http\Middleware\ResolveTenant
Signature:
/**
* @param Closure(Request): Response $next
*/
public function handle(
Request $request,
Closure $next,
string $panelId
): Response;2
3
4
5
6
7
8
Middleware ini hanya diregistrasikan untuk Panel yang mendeklarasikan:
tenant()Placement-nya berada di bagian akhir middleware Panel.
Secara konsep:
Resolve Panel
↓
Authentication
↓
User diketahui
↓
2FA / Email Code
↓
Resolve Tenant
↓
Controller2
3
4
5
6
7
8
9
10
11
Tenant di-resolve setelah user diketahui tetapi sebelum controller dapat melakukan query.
Panel tanpa tenancy tidak mendapatkan middleware ini.
Tiga Langkah Resolve Tenant
Seluruhnya harus berhasil.
1. Resolve Tenant
Panel::resolveTenant()digunakan untuk mengidentifikasi Tenant.
Jika hasilnya null:
abort(
404,
'No such tenant.'
);2
3
4
2. Authorize User
Tenancy::allows()memeriksa user.
Jika false:
abort(403);3. Bind Tenant
Jika Tenant ada dan user boleh mengakses:
Tenancy::bind(
$tenant
);2
3
dijalankan satu kali.
Flow:
ResolveTenant
↓
resolveTenant()
↓
Tenant ditemukan?
├── no
│ ↓
│ 404
│
└── yes
↓
Tenancy::allows()
↓
allowed?
├── no
│ ↓
│ 403
│
└── yes
↓
Tenancy::bind()
↓
Continue request2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Tidak Ada Middleware Alias untuk ResolveTenant
Alias middleware yang diregistrasikan:
panel
panel.two-factor
panel.email-code
panel.parent2
3
4
ResolveTenant tidak memiliki alias.
Jika Anda mendaftarkan route tenant-aware secara manual di luar route group PandaBear, gunakan class secara langsung.
Contoh:
use Illuminate\Support\Facades\Route;
use PandaPanel\Http\Middleware\ResolvePanel;
use PandaPanel\Http\Middleware\ResolveTenant;
Route::middleware([
'web',
'auth',
ResolvePanel::class
. ':app',
ResolveTenant::class
. ':app',
])
->get(
'/app/report',
ReportController::class
);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Apa yang Diterima Frontend
Middleware:
PandaPanel\Http\Middleware\SharePanelDatamembagikan prop:
tenancypada setiap Panel response.
Prop ini menggunakan Closure.
Dengan begitu jika screen tidak membutuhkan Tenant Switcher, query untuk mendapatkan available Tenant dapat tetap lazy.
Type frontend:
type PanelTenant = {
key: number | string
name: string
url: string | null
current: boolean
}
type Tenancy = {
current: PanelTenant | null
available: PanelTenant[]
} | null2
3
4
5
6
7
8
9
10
11
Panel Tanpa Tenancy
Untuk Panel yang tidak menggunakan tenancy:
tenancy === nullBukan:
{
current: null,
available: [],
}2
3
4
Ini membuat frontend dapat memeriksa dengan jelas:
if (tenancy === null) {
// Panel tidak menggunakan tenancy.
}2
3
available
List:
availableberasal dari:
Tenancy::availableTo()Jadi Tenant Switcher tidak menawarkan Tenant yang menurut application memang tidak tersedia untuk user.
Contoh test:
$this
->get(
'/app/documents?workspace=1'
)
->assertInertia(
fn (
AssertableInertia $page
) =>
$page
->where(
'tenancy.current.name',
'Acme'
)
->where(
'tenancy.available.0.current',
true
)
);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Lihat:
Exceptions
Ketiga exception tenancy berikut menggunakan:
PandaPanel\Exceptions\PanelRegistrationException| Factory | Dilempar oleh | Arti |
|---|---|---|
noCurrentTenant() | Tenancy::require() | Tenant-scoped read dijalankan tetapi tidak ada Tenant yang di-bind |
unknownTenantRelationship(string $resource, string $model, string $relation) | Resource::applyTenantScope() | $tenantRelationship menunjuk method yang tidak ada pada model |
tenantRelationshipIsNotARelation(string $resource, string $model, string $relation) | Resource::applyTenantScope() | Method ada tetapi return-nya bukan Eloquent Relation |
Testing Missing Tenant
Contoh:
use PandaPanel\Exceptions\PanelRegistrationException;
use PandaPanel\Tenancy\Tenancy;
expect(
fn () =>
DocumentResource::query()
->get()
)
->toThrow(
PanelRegistrationException::class
);2
3
4
5
6
7
8
9
10
11
Dengan Tenant:
expect(
Tenancy::for(
$workspace,
fn () =>
DocumentResource::query()
->count()
)
)
->toBe(2);2
3
4
5
6
7
8
9
10
Mengapa Relationship Error Memiliki Exception Khusus
Tanpa validation, Eloquent whereHas() terhadap method yang bukan relation dapat menghasilkan error seperti:
Call to a member function getRelated() on nullPesan tersebut hanya menjelaskan internal Eloquent.
Tidak memberi tahu:
- Resource mana;
- model mana;
- property
$tenantRelationshipmana;
yang menyebabkan masalah.
PandaBear menggantinya dengan exception yang lebih actionable.
Gotchas
Tenant Scope Menggunakan Primary Key, Bukan getTenantKey()
Ini detail penting.
Resource scoping menggunakan:
$tenant->getKey()melalui:
whereKey()Sedangkan:
Tenancy::keyOf()
Tenancy::describe()2
3
dan Tenant Switcher dapat menggunakan:
PanelTenant::getTenantKey()Contohnya Tenant:
database primary key:
41
public Tenant key:
acme2
3
4
5
URL dapat menggunakan:
workspace=acmeSwitcher dapat menampilkan identifier:
acmetetapi Resource scope tetap menggunakan:
id = 41Ini behavior yang disengaja.
Jadi jangan heran jika query log menggunakan numeric primary key walaupun URL menggunakan slug.
Tenancy::bind() Bukan Entry Point Application
Jangan:
foreach (
$tenants as $tenant
) {
Tenancy::bind(
$tenant
);
// ...
}2
3
4
5
6
7
8
9
Karena setelah loop, Tenant terakhir tetap ter-bind selama scoped context tersebut belum dibersihkan.
Gunakan:
Tenancy::for()karena Tenant sebelumnya selalu dipulihkan, termasuk jika callback melempar exception.
Queued Job Tidak Memiliki Tenant Secara Otomatis
PanelContext merupakan:
scoped()Queue worker membersihkan scoped instances di antara job.
Artinya queued job tidak mewarisi current Tenant dari HTTP request.
Job harus membawa Tenant identifier di payload.
Kemudian worker harus masuk kembali ke Tenant tersebut menggunakan:
Tenancy::for()Secara konsep:
HTTP Request
Tenant = Acme
↓
Dispatch Job
payload tenant_id = 41
↓
Worker
current Tenant = null
↓
Resolve Tenant 41
↓
Tenancy::for($tenant)
↓
Run tenant-aware work2
3
4
5
6
7
8
9
10
11
12
13
14
Lihat:
Resolver dengan Wrong Class Menghasilkan 404
Misalnya:
->tenant(
Workspace::class,
fn () =>
auth()->user()
)2
3
4
5
Resolver menghasilkan:
Userpadahal model yang dideklarasikan:
WorkspaceFramework memeriksa:
$tenant instanceof $modelJika salah:
resolveTenant() → nullkemudian middleware menghasilkan:
404Bukan runtime type confusion.
Perbedaan 404 dan 403
Tenancy membedakan dua kondisi.
404
Tenant tidak dapat diidentifikasiContohnya:
workspace=does-not-existhasil:
404403
Tenant berhasil ditemukan tetapi user tidak boleh memasukinya.
Contoh:
workspace=2 exists
+
user hanya memiliki workspace=12
3
hasil:
403PandaBear sengaja tidak menyamarkan semuanya sebagai 404.
Menurut desain sumber:
User sudah harus mengetahui/menyebut Tenant identifier untuk mencapai kondisi tersebut, sehingga menyembunyikan keberadaan Tenant tidak memberikan manfaat yang sebanding dengan hilangnya error yang jelas.
Tenancy adalah Property Panel, Bukan Resource
Resource yang sama dapat memiliki behavior berbeda berdasarkan Panel.
Contoh:
Admin Panel
→ no tenancy
→ DocumentResource melihat semua records
App Panel
→ tenancy enabled
→ DocumentResource scoped ke current Workspace2
3
4
5
6
7
Karena:
Resource::applyTenantScope()memeriksa current Panel terlebih dahulu.
Artinya Anda tidak harus membuat:
AdminDocumentResource
TenantDocumentResource2
hanya untuk perbedaan tenancy tersebut.
Panel Diregistrasikan saat Boot, Tenant Di-resolve per Request
Panel registration terjadi ketika application boot.
Tenant baru diketahui ketika request datang.
Karena itu jangan mencoba membuat:
satu Panel per Tenantsecara dinamis.
Contoh yang tidak sesuai konsep:
AdminPanelForTenant1
AdminPanelForTenant2
AdminPanelForTenant32
3
Gunakan satu Panel.
Perbedaan per-Tenant dapat dinyatakan melalui mekanisme seperti:
Resource::canViewAny()
Panel::canAccess()2
3
atau runtime configuration yang memang request-aware.
Ringkasan Arsitektur Tenancy
Panel
│
├── Tenant Model
│
├── Tenant Resolver
│
└── Tenant URL Builder
│
└───────────────┐
│
HTTP Request │
↓ │
ResolvePanel │
↓ │
Authentication │
↓ │
ResolveTenant ←─┘
↓
Tenant exists?
↓
User allowed?
↓
Tenancy::bind()
↓
PanelContext
↓
Resource::query()
↓
applyTenantScope()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
Ringkasan Three-Part Contract
PANEL
─────
Menjawab:
"Tenant request ini siapa?"
tenant(
Workspace::class,
resolver
)
USER
────
Menjawab:
"Tenant mana yang ditawarkan?"
"Tenant ini boleh dimasuki?"
HasPanelTenants
RESOURCE
────────
Menjawab:
"Record terhubung ke Tenant
lewat relationship apa?"
$tenantRelationship2
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
Ketiganya memiliki tanggung jawab berbeda.
Ringkasan Tenant Resolution
Request
↓
Panel::resolveTenant()
↓
Tenant Model?
├── no
│ ↓
│ 404
│
└── yes
↓
Tenancy::allows()
↓
allowed?
├── no
│ ↓
│ 403
│
└── yes
↓
Tenancy::bind()
↓
Controller2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Ringkasan Resource Scoping
Resource::query()
↓
Panel has tenancy?
├── no
│ ↓
│ unchanged
│
└── yes
↓
tenantRelationship?
├── no
│ ↓
│ unchanged
│
└── yes
↓
Tenancy::require()
↓
Tenant exists?
├── no
│ ↓
│ exception
│
└── yes
↓
whereHas(
relationship,
whereKey(
tenant primary key
)
)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
Ringkasan availableTo() vs allows()
getPanelTenants()
↓
availableTo()
↓
Tenant Switcher
↓
DISPLAY CONCERN2
3
4
5
6
7
Berbeda dengan:
canAccessPanelTenant()
↓
allows()
↓
ResolveTenant
↓
403 / Continue
↓
SECURITY CONCERN2
3
4
5
6
7
8
9
Jangan menyamakan kedua mekanisme tersebut.
Ringkasan Tenancy::for()
Tenant sebelum:
A
↓
Tenancy::for(B)
↓
Bind B
↓
Run callback
↓
callback selesai
atau exception
↓
finally
↓
Restore A2
3
4
5
6
7
8
9
10
11
12
13
14
15
Jika awalnya tidak ada Tenant:
null
→ B
→ null2
3
Ringkasan Database-per-Tenant
Jika database connection sudah di-switch oleh infrastructure lain:
Request
↓
stancl/tenancy
↓
DB connection = Tenant A
↓
PandaBear ResolveTenant
↓
Current Tenant = Tenant A
↓
Resource tenantRelationship = null
↓
No additional whereHas scope2
3
4
5
6
7
8
9
10
11
12
13
Ini valid.
PandaBear tidak harus menambahkan tenant foreign-key scope jika database itu sendiri sudah menjadi boundary.
Ringkasan Single-Database Tenancy
Shared Database
↓
Workspace 1
Workspace 2
Workspace 3
↓
Document
belongsTo Workspace
↓
$tenantRelationship =
'workspace'
↓
Resource::query()
↓
whereHas workspace = current Tenant2
3
4
5
6
7
8
9
10
11
12
13
14
15
Ringkasan Frontend Payload
tenancy = {
current: {
key: 41,
name: 'Acme',
url: '/app/documents?workspace=41',
current: true,
},
available: [
// ...
],
}2
3
4
5
6
7
8
9
10
11
12
Panel tanpa tenancy:
tenancy = nullRingkasan Failure Modes
Tenant identifier tidak ditemukan
→ 404
Tenant ditemukan tetapi user ditolak
→ 403
Resource membutuhkan Tenant tetapi
current Tenant tidak di-bind
→ PanelRegistrationException
tenantRelationship tidak ada pada model
→ PanelRegistrationException
tenantRelationship method ada tetapi
bukan Eloquent Relation
→ PanelRegistrationException
resolver mengembalikan model type salah
→ dianggap null
→ 4042
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Prinsip Utama Tenancy
PandaBear memisahkan tenancy menjadi tiga concern:
Identification
→ Panel::tenant()
Authorization
→ HasPanelTenants
Data Scoping
→ Resource::$tenantRelationship2
3
4
5
6
7
8
Framework tidak mencoba menentukan bagaimana application Anda menjalankan multi-tenancy.
Application dapat menggunakan:
single database
database per tenant
subdomain
path
query string
user-owned workspace
stancl/tenancy
custom tenancy package2
3
4
5
6
7
8
selama Panel dapat menjawab:
Tenant request ini siapa?
dan User dapat menjawab:
Apakah Tenant tersebut boleh diakses?
serta Resource dapat menjawab, jika diperlukan:
Bagaimana record ini terhubung dengan Tenant?
Prinsip security terpentingnya:
Resource yang mendeklarasikan tenant scope tidak pernah fallback ke query tanpa Tenant. Jika Tenant seharusnya ada tetapi tidak di-bind, framework gagal dengan keras daripada mengembalikan data seluruh Tenant.
Prinsip lifecycle terpentingnya:
Panel diregistrasikan saat application boot, tetapi Tenant di-resolve per request dan disimpan dalam request-scoped
PanelContext, sehingga context Tenant tidak menjadi global mutable state.
Dan untuk background processing:
Queue, console, dan process di luar HTTP request harus membawa Tenant context secara eksplisit dan memasuki Tenant melalui
Tenancy::for().