Konsep Tenancy
Tenancy di PandaBear memiliki satu tanggung jawab yang spesifik: memberikan jawaban yang stabil dan teruji terhadap pertanyaan request ini ditujukan untuk tenant yang mana, mengikat tenant tersebut sebelum controller apa pun dijalankan, lalu menerapkan pemeriksaan akses dan scope query yang mengikutinya. Ini bukan implementasi multi-tenancy lengkap — PandaBear tidak membuat database, mengganti connection, mempartisi cache, atau membaca subdomain. Gunakan fitur ini ketika record pada sebuah panel dimiliki oleh team, workspace, organisasi, atau akun pelanggan dan Anda ingin scope tersebut ditegakkan dari satu tempat, bukan dihitung ulang di setiap resource.
Panel tenant yang berfungsi
Ada tiga bagian, dan semuanya wajib. Panel menentukan apa yang disebut tenant dan bagaimana menemukannya; model user menentukan tenant mana saja yang boleh diakses; resource menentukan bagaimana record miliknya terhubung ke 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
<?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
<?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;
/** The relationship on Document that leads to a Workspace. */
protected static ?string $tenantRelationship = 'workspace';
// table(), form(), pages() as usual
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
GET /app/documents?workspace=7 sekarang hanya menampilkan dokumen milik workspace 7, dan hanya kepada user yang memang menjadi bagian dari workspace tersebut. Query parameter adalah skema identifikasi paling sederhana yang tetap bekerja dan digunakan oleh test suite framework; aplikasi nyata biasanya mengidentifikasi tenant melalui subdomain atau segmen path — lihat URL Tenant.
Apa yang terjadi pada sebuah request
PandaPanel\Routing\PanelRouteRegistrar menambahkan PandaPanel\Http\Middleware\ResolveTenant ke route group milik panel, dan hanya untuk panel yang memanggil tenant(). Middleware ini berjalan paling akhir di antara middleware bawaan framework — setelah ResolvePanel, RequireTwoFactor, dan RequireEmailCode — karena ia meminta resolver dari panel lalu meneruskan user yang sudah terautentikasi ke resolver tersebut.
$panel = app(PanelManager::class)->get($panelId);
if (! $panel->hasTenancy()) {
return $next($request);
}
$user = $request->user();
$tenant = $panel->resolveTenant($request, $user);
abort_if($tenant === null, 404, 'No such tenant.');
abort_unless(Tenancy::allows($user, $tenant, $panel), 403);
Tenancy::bind($tenant);2
3
4
5
6
7
8
9
10
11
12
13
14
Ada tiga tahap, dan semuanya harus berhasil sebelum query apa pun dijalankan:
| Tahap | Penentu | Jika gagal |
|---|---|---|
| Identifikasi | resolver tenant() milik panel | 404 No such tenant. |
| Otorisasi | HasPanelTenants::canAccessPanelTenant() | 403 |
| Binding | Tenancy::bind() | — |
Tenant yang tidak dapat diidentifikasi menghasilkan 404, bukan redirect: request menunjuk sesuatu yang memang tidak ada. Tenant yang ada tetapi tidak boleh diakses user menghasilkan 403, sengaja bukan 404 — menyembunyikan tenant mana yang ada dari seseorang yang sudah harus menyebutkan tenant tersebut hanya akan mengorbankan pesan error yang mudah dipahami tanpa memberikan manfaat nyata.
Di mana tenant disimpan
PandaPanel\Tenancy\Tenancy menyimpannya di PandaPanel\Support\PanelContext dengan key panel.tenant, bukan di static state. Context tersebut di-bind menggunakan scoped(), sehingga tenant hidup tepat selama request berlangsung dan tidak dapat bocor antar-request, antar-test, atau antara dua request dalam satu worker Octane — alasan yang sama mengapa current panel dan current parent record juga disimpan di sana. Lihat Panel Context.
API Tenancy
PandaPanel\Tenancy\Tenancy bersifat final dan semua method-nya static. Tidak ada instance yang perlu dibuat atau dependency yang perlu di-inject.
| Method | Signature | Hasil |
|---|---|---|
bind | bind(Model $tenant): void | — |
current | current(): ?Model | tenant yang sedang terikat, atau null |
require | require(): Model | tenant, atau melempar exception |
key | key(): int|string|null | key tenant saat ini |
keyOf | keyOf(Model $tenant): int|string | key dari satu tenant |
nameOf | nameOf(Model $tenant): string | nama tenant untuk ditampilkan |
describe | describe(Model $tenant): array{key: int|string, name: string} | satu tenant dalam bentuk yang diterima frontend |
availableTo | availableTo(?Authenticatable $user, Panel $panel): list<Model> | daftar tenant untuk switcher |
allows | allows(?Authenticatable $user, Model $tenant, Panel $panel): bool | hasil pemeriksaan akses per request |
for | for(Model $tenant, callable $callback): mixed | nilai yang dikembalikan callback |
use App\Models\Workspace;
use PandaPanel\Tenancy\Tenancy;
Tenancy::current(); // ?Model — null outside a tenant panel
Tenancy::current()?->getKey(); // 7
Tenancy::key(); // 7, or null when there is no tenancy
Tenancy::require(); // Model, or PanelRegistrationException
$workspace = Workspace::query()->find(7);
Tenancy::keyOf($workspace); // 7
Tenancy::nameOf($workspace); // 'Acme'
Tenancy::describe($workspace); // ['key' => 7, 'name' => 'Acme']2
3
4
5
6
7
8
9
10
11
12
13
14
require() melempar PandaPanel\Exceptions\PanelRegistrationException alih-alih menurunkan perilaku menjadi "tanpa scope". Resource yang menyatakan dirinya tenant-scoped tetapi tidak memiliki tenant yang terikat berarti route tersebut berjalan tanpa ResolveTenant; jika query dibiarkan berjalan tanpa scope, semua record milik seluruh tenant dapat terlihat kepada siapa pun yang melakukan request.
Masuk ke konteks tenant di luar request
use PandaPanel\Tenancy\Tenancy;
$titles = Tenancy::for($workspace, static fn (): array => DocumentResource::query()
->pluck('title')
->all());2
3
4
5
for() melakukan bind, menjalankan callback, lalu mengembalikan binding sebelumnya di dalam finally. Dengan begitu callback yang melempar exception tidak membuat sisa proses tetap berada di scope tenant milik pihak lain. Inilah entry point yang didukung untuk command console yang melakukan loop antar-tenant, job yang perlu masuk kembali ke tenant asal ketika di-queue, dan test yang memastikan dua tenant melihat kumpulan row yang berbeda.
Tenancy::bind($acme);
try {
Tenancy::for($beta, fn () => throw new RuntimeException('nope'));
} catch (RuntimeException) {
// ...
}
Tenancy::current()?->getKey(); // still Acme2
3
4
5
6
7
8
9
Jika sebelumnya tidak ada tenant yang terikat, setelah callback selesai kondisi tersebut tetap dipertahankan:
Tenancy::for($acme, fn () => null);
Tenancy::current(); // null2
3
bind() bersifat public karena dibutuhkan oleh ResolveTenant dan test. Kode lain sebaiknya tidak memanggilnya: binding yang dilakukan di tengah request berarti scope baru aktif setelah bagian sebelumnya mungkin sudah menjalankan query tanpa scope.
Dua contract utama
| Contract | Diimplementasikan oleh | Wajib? |
|---|---|---|
PandaPanel\Contracts\HasPanelTenants | model user | Ya, untuk setiap panel dengan tenancy |
PandaPanel\Contracts\PanelTenant | model tenant | Opsional |
HasPanelTenants membuat panel tenant benar-benar dapat diakses. Model user yang tidak mengimplementasikannya dianggap tidak memiliki tenant apa pun oleh panel, sehingga Tenancy::allows() menghasilkan false dan setiap request berakhir sebagai 403 — penolakan yang jelas, bukan panel yang terbuka tanpa pengamanan. Lihat HasPanelTenants.
PanelTenant mendefinisikan nilai apa yang mengidentifikasi tenant serta nama apa yang ditampilkan di layar. Tanpa contract ini, Tenancy menggunakan primary key dan atribut name sebagai fallback. Lihat PanelTenant.
Data yang diterima frontend
PandaPanel\Http\Middleware\SharePanelData membagikan prop tenancy — bernilai null untuk panel tanpa tenancy. Karena itu frontend cukup memeriksa tenancy === null, dan elemen yang berkaitan dengan tenant tidak akan dirender pada aplikasi yang memang tidak menggunakan tenancy.
import { usePanel } from '@/panel/composables/usePanel';
const { tenancy, canSwitchTenants } = usePanel();
tenancy.value?.current?.name; // 'Acme'
tenancy.value?.available; // PanelTenantSummary[]2
3
4
5
6
Lihat Tenant Switcher dan Metadata Server ke Vue.
Hal-hal yang bukan tanggung jawab fitur ini
| Tanggung jawab | Pemilik |
|---|---|
| Membuat database tenant | stancl/tenancy, atau kode aplikasi Anda |
| Mengganti database connection | stancl/tenancy |
| Mempartisi cache, filesystem, dan queue | bootstrapper stancl/tenancy |
| Menentukan arti sebuah subdomain | resolver Anda |
| Membangun URL tenant | closure tenantUrlUsing() Anda |
Menulis tenant_id saat create | model observer atau global scope |
Resolver diwajibkan dan tidak diberi default karena setiap pilihan default yang tampak masuk akal hanya benar untuk arsitektur tertentu, tetapi dapat menjadi sumber kebocoran data yang tidak terlihat pada arsitektur lain. Lihat Menggunakan stancl/tenancy.
Catatan
- Guest page milik panel tidak memiliki tenant.
login,register,forgot-password,reset-password/{token}, danverify-emaildidaftarkan hanya dengan middleware base panel danResolvePanel, sehinggaResolveTenanttidak pernah berjalan di sana danTenancy::current()bernilai null. - Tenancy adalah properti panel, bukan resource. Class resource yang sama dapat didaftarkan di tenant panel dan admin panel; di tenant panel ia di-scope, sedangkan di admin panel ia tetap penuh —
applyTenantScope()mengembalikan query tanpa perubahan ketikapanel()bernilai null atauhasTenancy()false. - Resource yang tidak menyebut relationship tidak di-scope. Ini adalah mekanisme opt-in, bukan kelalaian: global lookup table dan arsitektur database-per-tenant memang sama-sama tidak memiliki sesuatu yang perlu di-scope melalui relationship.
Tenancy::key()tidak selalu sama dengancurrent()->getKey(). Method ini melewatikeyOf(), yang terlebih dahulu memanggilPanelTenant::getTenantKey(). Tenant yang diidentifikasi berdasarkan slug akan mengembalikan slug di sini.- Tidak ada alias middleware
ResolveTenant. Empat alias yang terdaftar adalahpanel,panel.two-factor,panel.email-code, danpanel.parent. Route yang didaftarkan manual harus menyebut class-nya dan meneruskan panel id:ResolveTenant::class.':'.$panel->getId(). - Panel didaftarkan saat boot; tenant di-resolve per request. Panel tidak dapat didaftarkan satu per tenant. Perbedaan perilaku per tenant sebaiknya diekspresikan melalui
Resource::canViewAny()danPanel::canAccess().