Tenant Panel
Contoh workspace Panel pada /app/{workspace}, tempat setiap record yang dilihat user hanya boleh berasal dari workspace yang disebut pada URL. Halaman ini membangunnya dari nol: table, tenant model, dua contract, panel provider, Resource yang di-scope, switcher, middleware yang mempertahankan tenant pada setiap link, serta test yang membuktikan dua tenant melihat dua set row yang berbeda.
Framework menyediakan satu hal: jawaban yang stabil dan teruji terhadap pertanyaan "request ini milik tenant yang mana?", sehingga Resource dapat melakukan scope terhadap jawaban tersebut tanpa setiap project harus menemukan ulang logic-nya di dalam override query(). Framework tidak membuat database, mengganti connection, mempartisi cache, atau menentukan arti subdomain — stancl/tenancy melakukan keempatnya dengan baik. Keduanya dapat digunakan bersama; lihat Using with stancl/tenancy.
Recipe ini menggunakan single database karena di sinilah scoping row paling mudah salah. Pada database/connection per tenant, boundary utamanya adalah connection itu sendiri sehingga tidak ada row lintas tenant pada connection yang sama untuk di-scope.
Contoh minimal yang berfungsi
use App\Models\Workspace;
use Illuminate\Http\Request;
return $panel
->path('app/{workspace}')
->auth()
->tenant(
Workspace::class,
static fn (Request $request): ?Workspace => Workspace::query()
->where('slug', $request->route('workspace'))
->first(),
);2
3
4
5
6
7
8
9
10
11
12
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';
}2
3
4
5
6
7
Tambahkan HasPanelTenants pada user model. Dengan itu tenancy aktif: middleware me-resolve dan meng-authorize workspace pada setiap request yang masuk ke Panel, lalu seluruh pembacaan Resource tersebut dipersempit ke workspace itu.
Table
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('workspaces', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->timestamps();
});
Schema::create('documents', function (Blueprint $table): void {
$table->id();
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->text('body')->nullable();
$table->timestamps();
// The scope is a whereHas on this column. Index it.
$table->index('workspace_id');
});
// Who may enter which workspace.
Schema::create('workspace_user', function (Blueprint $table): void {
$table->id();
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->timestamps();
$table->unique(['workspace_id', 'user_id']);
});
}
};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
Tenant model
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Contracts\PanelTenant;
final class Workspace extends Model implements PanelTenant
{
protected $fillable = ['name', 'slug'];
/**
* The value this tenant is identified by. The slug, because that is what
* the URL carries — `Tenancy` looks tenants up by whatever this returns.
*/
public function getTenantKey(): int|string
{
return (string) $this->getAttribute('slug');
}
public function getTenantName(): string
{
return (string) $this->getAttribute('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
PandaPanel\Contracts\PanelTenant hanya memiliki dua method dan sengaja tidak akan berkembang menjadi model bisnis lengkap. Apa sebenarnya tenant itu — team, organisation, customer account, atau database — adalah urusan aplikasi. Framework yang meminta plan, logo, atau property domain-specific lain justru akan mendeskripsikan tenant milik satu project tertentu, bukan konsep tenant secara umum.
Contract ini opsional. Tanpanya, Tenancy fallback ke primary key dan attribute name:
Tenancy::keyOf(Model $tenant): int|string // the contract, then getKey()
Tenancy::nameOf(Model $tenant): string // the contract, then `name`, then the key
Tenancy::describe(Model $tenant): array // ['key' => …, 'name' => …]2
3
Fallback terakhir menggunakan key, bukan string kosong, secara sengaja. Switcher dengan row tanpa nama tidak dapat digunakan; label 41 setidaknya masih mengidentifikasi tenant tertentu.
User model
<?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);
}
/**
* The switcher's list, and the pool a default is chosen from.
*
* @return Collection<int, Model>
*/
public function getPanelTenants(Panel $panel): Collection
{
/** @var Collection<int, Model> $workspaces */
$workspaces = $this->workspaces()->orderBy('name')->get();
return $workspaces;
}
/**
* Asked on every request, before anything is queried.
*
* An independent query, never `getPanelTenants()->contains(...)`: that
* list is built for a dropdown and may be sorted, trimmed, or paginated,
* and a security answer must not change when a display decision does.
*/
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
User model tanpa contract ini dianggap tidak memiliki tenant apa pun pada tenant-scoped Panel dan setiap request ke Panel akan ditolak. Ini failure mode yang benar dan eksplisit: default paling berbahaya justru jika tenant Panel menjadi terbuka karena user model belum diperbarui.
canAccessPanelTenant() berjalan pada setiap request Panel. Pertahankan logic-nya sebagai satu indexed exists() atau pemeriksaan yang setara ringan.
Panel provider
<?php
declare(strict_types=1);
namespace App\Panels\App;
use App\Http\Middleware\DefaultWorkspaceUrlParameter;
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
// The path is the route group's prefix verbatim, so `{workspace}`
// is an ordinary route parameter on every route the panel
// registers.
->path('app/{workspace}')
->name('Workspace')
->auth()
// `middleware()` REPLACES the base stack, so `web` is listed.
// This runs before ResolvePanel and ResolveTenant, which is why it
// reads the route parameter rather than Tenancy::current().
->middleware(['web', DefaultWorkspaceUrlParameter::class])
->tenant(
Workspace::class,
static fn (Request $request): ?Workspace => Workspace::query()
->where('slug', $request->route('workspace'))
->first(),
)
->tenantUrlUsing(
static fn (Workspace $workspace, Panel $panel): string
=> '/app/'.$workspace->slug,
)
->discoverResources(app_path('Panels/App/Resources'))
->discoverPages(app_path('Panels/App/Pages'))
->discoverWidgets(app_path('Panels/App/Widgets'));
}
}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
tenant()
/**
* @param class-string<Model> $model
* @param Closure(Request, ?Authenticatable): ?Model $resolver
*/
public function tenant(string $model, Closure $resolver): self
public function hasTenancy(): bool
public function getTenantModel(): ?string
public function resolveTenant(Request $request, ?Authenticatable $user): ?Model2
3
4
5
6
7
8
9
Hanya dua argument tersebut yang diperlukan untuk mengaktifkan tenancy — tidak ada toggle lain. Memanggil tenant() memiliki empat efek:
- setiap route group yang diregistrasikan Panel mendapat
PandaPanel\Http\Middleware\ResolveTenant; PandaPanel\Tenancy\Tenancy::current()dapat menjawab current tenant untuk sisa request;- Resource yang mendeklarasikan
tenantRelationship()otomatis di-scope, sedangkan Resource tanpa deklarasi tetap seperti semula; - daftar tenant switcher dibagikan ke frontend.
Resolver harus mengembalikan model atau null. Return value yang bukan instance dari model yang dideklarasikan diperlakukan sebagai tidak ada tenant. Guard ini mencegah bug seperti resolver tanpa sengaja mengembalikan user lalu seluruh query di-scope berdasarkan user ID dan sekilas terlihat bekerja.
Tiga bentuk resolver berikut semuanya valid, tergantung cara tenant di-address:
// Path segment — this recipe.
->tenant(Workspace::class, fn (Request $request) => Workspace::query()
->where('slug', $request->route('workspace'))->first())
// Database per tenant, identified by subdomain: stancl has already switched
// the connection by the time this runs, so the resolver reads it back.
->tenant(Tenant::class, fn () => tenant())
// One tenant per user, nothing in the URL at all.
->tenant(Workspace::class, fn ($request, $user) => $user?->workspace)2
3
4
5
6
7
8
9
10
tenantUrlUsing()
/** @param Closure(Model, self): string $url */
public function tenantUrlUsing(Closure $url): self
public function getTenantUrl(Model $tenant): ?string2
3
4
Method ini adalah kebalikan resolver: mengubah tenant kembali menjadi URL request agar switcher memiliki destination. Hanya pembuat resolver yang mengetahui bagaimana tenant di-address, sehingga hanya aplikasi yang dapat membalikkan mapping itu secara akurat.
Tanpa URL builder, getTenantUrl() mengembalikan null untuk seluruh tenant dan switcher tidak dirender. Panel tetap dapat me-resolve, meng-authorize, dan melakukan scope dengan benar; hanya tidak menyediakan UI untuk berpindah tenant.
Pada skema subdomain, URL harus absolute karena switcher berpindah ke origin lain dan memang menggunakan plain <a> untuk navigasi tersebut.
Mempertahankan tenant pada setiap link
Resource::url() dan Page::url() membangun seluruh link Panel melalui route name Laravel. Parameter yang mereka kirim berisi record dan, untuk nested resource, parent — tetapi bukan tenant. Framework tidak mengetahui nama route parameter aplikasi Anda, sehingga menambahkan parameter tenant sendiri hanya akan menjadi tebakan.
Gunakan URL::defaults() milik Laravel:
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpFoundation\Response;
final class DefaultWorkspaceUrlParameter
{
public function handle(Request $request, Closure $next): Response
{
$workspace = $request->route('workspace');
if (is_string($workspace)) {
URL::defaults(['workspace' => $workspace]);
}
return $next($request);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Setelah middleware tersebut masuk ke stack Panel:
DocumentResource::url(); // '/app/acme/documents'
DocumentResource::url('edit', $document); // '/app/acme/documents/12/edit'2
Skema query parameter tidak memiliki equivalent langsung karena URL::defaults() hanya mengisi route parameter. Panel tetap dapat resolve dan switch, tetapi link internal akan kehilangan tenant. Karena itu bentuk query parameter lebih cocok untuk test harness daripada production navigation.
Resource yang di-scope
<?php
declare(strict_types=1);
namespace App\Panels\App\Resources\Documents;
use App\Models\Document;
use App\Panels\App\Resources\Documents\Pages\CreateDocument;
use App\Panels\App\Resources\Documents\Pages\EditDocument;
use App\Panels\App\Resources\Documents\Pages\ListDocuments;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\Components\Textarea;
use PandaPanel\Forms\FormSchema;
use PandaPanel\Resources\Resource;
use PandaPanel\Tables\Columns\DateTimeColumn;
use PandaPanel\Tables\Columns\TextColumn;
use PandaPanel\Tables\TableSchema;
final class DocumentResource extends Resource
{
protected static string $model = Document::class;
protected static ?string $slug = 'documents';
protected static ?string $navigationIcon = 'file-text';
/**
* The relationship on Document that leads to the tenant. Naming it is
* the whole opt-in; a resource that names nothing is left unscoped.
*/
protected static ?string $tenantRelationship = 'workspace';
public static function table(TableSchema $table): TableSchema
{
return $table->columns([
TextColumn::make('title')->searchable()->sortable(),
DateTimeColumn::make('updated_at')->label('Changed')->relative()->sortable(),
]);
}
public static function form(FormSchema $schema): FormSchema
{
// No workspace_id field. The record is written through the panel's
// own create page, and the owning workspace is not the user's to
// choose.
return $schema->schema([
TextInput::make('title')->required()->maxLength(255),
Textarea::make('body')->rows(10),
]);
}
/**
* @return array<string, class-string>
*/
public static function pages(): array
{
return [
'index' => ListDocuments::class,
'create' => CreateDocument::class,
'edit' => EditDocument::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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// app/Models/Document.php
/**
* @return BelongsTo<Workspace, $this>
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}2
3
4
5
6
7
8
9
Menentukan tenant saat create
Scoping mempersempit pembacaan. Record baru tetap membutuhkan foreign key tenant, dan value tersebut tidak boleh berasal dari form:
use PandaPanel\Resources\Pages\CreateRecord;
use PandaPanel\Tenancy\Tenancy;
final class CreateDocument extends CreateRecord
{
protected static string $resource = DocumentResource::class;
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeCreate(array $data): array
{
// require(), not current(): a create that ran with no tenant bound
// would write a row belonging to nobody.
$data['workspace_id'] = Tenancy::require()->getKey();
return $data;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Keluarkan workspace_id dari $fillable model juga. Dua lock tetap lebih baik: schema membuang key yang tidak pernah dideklarasikan, dan mass assignment menolak key tersebut jika suatu jalur lain mencoba meneruskannya.
Cara scope diterapkan
Resource::query() adalah funnel tunggal — list, record lookup, action, bulk action, global search, dan export semuanya melewati query ini.
protected static function applyTenantScope(Builder $query): Builder
{
// 1. the panel has tenancy, 2. the resource names a relationship,
// 3. a tenant is bound.
$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
Kondisi ketiga adalah throw, bukan skip. Ini keputusan penting. Resource yang mendeklarasikan dirinya tenant-scoped tetapi berjalan tanpa tenant ter-bind akan mengembalikan seluruh tenant jika scope hanya dilewati — dan page tetap terlihat seperti page yang bekerja. Tenancy::require() mengubah kondisi tersebut menjadi PandaPanel\Exceptions\PanelRegistrationException.
Dua kesalahan konfigurasi lain juga ditangkap dengan exception yang menyebut nama masalahnya, bukan dibiarkan jatuh ke internal Eloquent:
- Relationship tidak ada pada model →
PanelRegistrationException::unknownTenantRelationship(), menyebut Resource, model, dan method. - Method ada tetapi bukan relation — misalnya scope, accessor, atau helper →
PanelRegistrationException::tenantRelationshipIsNotARelation(). Tanpa check ini failure akan muncul di dalamwhereHasdengan pesan seperti "Call to a member function getRelated() on null" yang tidak menjelaskan deklarasi mana yang salah.
Resource yang sengaja tidak mendeklarasikan relationship tetap unscoped. Ini bisa benar untuk plan table yang sama bagi seluruh tenant, country list, atau database-per-tenant ketika connection sendiri sudah menjadi boundary. Dokumentasikan pengecualian tersebut agar "sengaja global" tidak terlihat sama dengan "lupa diberi tenantRelationship".
Override method ketika jawabannya bergantung pada kondisi yang tidak dapat dinyatakan property:
public static function tenantRelationship(): ?string
{
return panel()?->getId() === 'app' ? 'workspace' : null;
}2
3
4
Tenancy adalah property milik Panel. Class Resource yang sama dapat tenant-scoped pada App Panel tetapi tetap global pada Admin Panel.
Masuk ke tenant di luar request
/**
* @template TReturn
*
* @param callable(): TReturn $callback
* @return TReturn
*/
public static function for(Model $tenant, callable $callback): mixed2
3
4
5
6
7
Gunakan untuk pekerjaan yang memang sah melewati boundary request: console command yang melakukan loop seluruh tenant, queued job yang harus masuk kembali ke tenant asal, atau test yang membuktikan dua tenant melihat row berbeda.
use PandaPanel\Tenancy\Tenancy;
foreach (Workspace::query()->cursor() as $workspace) {
Tenancy::for($workspace, static function () use ($workspace): void {
$this->info($workspace->name.': '.DocumentResource::query()->count());
});
}2
3
4
5
6
7
Binding sebelumnya selalu dikembalikan melalui finally, sehingga callback yang melempar exception tidak membuat sisa process tertinggal pada tenant orang lain.
API lainnya:
Tenancy::bind(Model $tenant): void // only ResolveTenant and tests should call this
Tenancy::current(): ?Model
Tenancy::require(): Model // throws rather than running unscoped
Tenancy::key(): int|string|null
Tenancy::keyOf(Model $tenant): int|string
Tenancy::nameOf(Model $tenant): string
Tenancy::describe(Model $tenant): array
Tenancy::availableTo(?Authenticatable $user, Panel $panel): array
Tenancy::allows(?Authenticatable $user, Model $tenant, Panel $panel): bool2
3
4
5
6
7
8
9
Tenant disimpan pada PandaPanel\Support\PanelContext, yaitu container binding scoped(), bukan static property. Lifetime-nya sama dengan request dan tidak bocor antar-request, antar-test, atau antar dua request di worker Octane yang sama.
Queued jobs
Binding tenant tidak ikut dibawa queued job. Queue worker Laravel memanggil forgetScopedInstances() antar-job, sehingga setiap job selalu mulai tanpa tenant ter-bind.
final class RebuildWorkspaceIndex implements ShouldQueue
{
use Queueable;
// The key, not the model. A serialized model reloads on the far side
// through whatever connection is current.
public function __construct(private readonly int $workspaceKey) {}
public function handle(): void
{
$workspace = Workspace::query()->findOrFail($this->workspaceKey);
Tenancy::for($workspace, static function (): void {
DocumentResource::query()->each(/* … */);
});
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Bawa key, resolve model pada sisi worker, lalu masuk ke tenant menggunakan Tenancy::for().
Switcher
Tidak ada registration tambahan. PandaPanel\Http\Middleware\SharePanelData membagikan prop tenancy, lalu shell merender switcher ketika tiga kondisi berikut terpenuhi:
| Kondisi | Ditentukan oleh |
|---|---|
| Panel mendeklarasikan tenancy | Panel::tenant() — jika tidak, tenancy bernilai null |
| User boleh masuk ke lebih dari satu tenant | HasPanelTenants::getPanelTenants() mengembalikan dua atau lebih |
| Setidaknya satu entry memiliki URL | Panel::tenantUrlUsing() sudah dipanggil |
User yang hanya memiliki satu tenant tidak melihat switcher karena memang tidak ada destination lain.
Prop tersebut berupa closure, sehingga screen Panel yang tidak pernah membutuhkan switcher tidak memaksa query daftar tenant berjalan lebih awal. Pada Panel tanpa tenancy nilainya null, bukan object kosong; frontend cukup memeriksa tenancy === null.
[
'current' => ['key' => 'acme', 'name' => 'Acme'],
'available' => [
['key' => 'acme', 'name' => 'Acme', 'url' => '/app/acme', 'current' => true],
['key' => 'beta', 'name' => 'Beta', 'url' => '/app/beta', 'current' => false],
],
]2
3
4
5
6
7
Daftar dibangun dari Tenancy::availableTo(), yaitu pool yang konsisten dengan authorization check per-request. Switcher tidak menawarkan tenant yang ketika dibuka akan menghasilkan 403.
Apa yang dilakukan middleware
ResolveTenant ditambahkan paling akhir pada stack Panel: setelah user diketahui dan sebelum controller dapat menjalankan query.
$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
| Kondisi | Response | Alasan |
|---|---|---|
| Resolver tidak menemukan tenant | 404 | request menyebut sesuatu yang tidak ada |
| User tidak memiliki tenant tersebut | 403 | user sudah menyebut tenant secara eksplisit; menyamarkan existence-nya menjadi 404 hanya membuat error kurang jelas |
User model tidak mengimplementasikan HasPanelTenants | 403 | user dianggap tidak memiliki tenant |
| Panel tidak mendeklarasikan tenancy | pass through | middleware bahkan tidak diregistrasikan |
Test
<?php
declare(strict_types=1);
use App\Models\Document;
use App\Models\User;
use App\Models\Workspace;
use App\Panels\App\Resources\Documents\DocumentResource;
use Inertia\Testing\AssertableInertia;
use PandaPanel\Exceptions\PanelRegistrationException;
use PandaPanel\Tenancy\Tenancy;
beforeEach(function (): void {
$this->acme = Workspace::query()->create(['name' => 'Acme', 'slug' => 'acme']);
$this->beta = Workspace::query()->create(['name' => 'Beta', 'slug' => 'beta']);
Document::query()->create(['workspace_id' => $this->acme->id, 'title' => 'Acme plan']);
Document::query()->create(['workspace_id' => $this->acme->id, 'title' => 'Acme notes']);
Document::query()->create(['workspace_id' => $this->beta->id, 'title' => 'Beta secrets']);
$this->user = User::factory()->create();
$this->user->workspaces()->attach($this->acme);
$this->actingAs($this->user);
});
it('shows one tenant\'s records and not the other\'s', function (): void {
$this->user->workspaces()->attach($this->beta);
// Every read goes through query(), so proving it there proves the list,
// the record lookup, the actions, and global search at once.
$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('refuses a tenant this user does not belong to', function (): void {
$this->get('/app/beta/documents')->assertForbidden();
$this->get('/app/acme/documents')->assertOk();
});
it('answers 404 for a tenant that is not there', function (): void {
$this->get('/app/nothing-like-this/documents')->assertNotFound();
});
it('refuses a user model that does not know about tenants at all', function (): void {
// HasPanelTenants is what makes a tenant-scoped panel answerable. Without
// it a user belongs to nothing, which is a refusal rather than a panel
// that falls open.
$this->actingAs(new class extends User {});
$this->get('/app/acme/documents')->assertForbidden();
});
it('raises rather than running unscoped when no tenant is bound', function (): void {
// The failure the whole mechanism exists to prevent. A scoped resource
// that ran without a tenant would return every tenant's records and look
// like a working page.
expect(fn () => DocumentResource::query()->get())
->toThrow(PanelRegistrationException::class);
});
it('restores the previous tenant even when the callback throws', function (): void {
Tenancy::bind($this->acme);
try {
Tenancy::for($this->beta, static fn () => throw new RuntimeException('nope'));
} catch (RuntimeException) {
// Expected. What matters is what is bound afterwards.
}
expect(Tenancy::current()?->getKey())->toBe($this->acme->getKey());
});
it('shares the current tenant and the ones this user could switch to', function (): void {
$this->user->workspaces()->attach($this->beta);
$this->get('/app/acme/documents')
->assertInertia(fn (AssertableInertia $page) => $page
->where('tenancy.current.name', 'Acme')
->where('tenancy.available.0.name', 'Acme')
->where('tenancy.available.0.current', true)
->where('tenancy.available.1.url', '/app/beta'));
});
it('keeps the tenant in the URLs the panel builds', function (): void {
$this->get('/app/acme/documents')->assertOk();
// URL::defaults(), set by the panel's own middleware from the route
// parameter. Without it every link inside the panel loses the tenant.
expect(DocumentResource::url())->toBe('/app/acme/documents');
});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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
php artisan test --compact --filter=Tenan
php artisan route:list --path=app # ResolveTenant must be on every row2
tests/Feature/Panel/TenancyTest.php adalah test bawaan framework untuk property yang sama, menggunakan fixture Workspace / Document / TenantUser dan query-parameter resolver. Test framework berfokus pada behavior setelah tenant diidentifikasi karena cara identifikasi tenant adalah keputusan aplikasi.
Hal yang perlu diperhatikan
- Resource yang tidak mendeklarasikan relationship tetap unscoped tanpa warning. Deklarasi relationship adalah opt-in. Resource yang terlupa terlihat sama dengan Resource yang memang global. Dokumentasikan daftar exception yang sengaja unscoped.
- Override
query()yang tidak memanggilparent::query()membuang tenant scope bersama eager load dan per-panel narrowing, sementara page tetap dapat merender seperti biasa. middleware()mengganti base stack. MenambahkanDefaultWorkspaceUrlParametertanpa ikut menuliskanwebakan membuang session dan CSRF.- Central Panel membutuhkan
domain()pada skema subdomain. Tanpanyaadmin.example.testdapat diidentifikasi sebagai tenant bernamaadmin. - Tenant context tidak bertahan ke queued job. Bawa key lalu masuk kembali menggunakan
Tenancy::for(). Tenancy::bind()bukan API aplikasi untuk dipanggil sembarang tempat. Binding di tengah request berarti seluruh query sebelumnya sudah berjalan tanpa scope. GunakanResolveTenantatauTenancy::for().canAccessPanelTenant()berjalan pada setiap request. Pertahankan sebagai satu indexedexists(); logic lebih berat berarti biaya tambahan pada setiap page load.- User yang tidak memiliki tenant mendapat 403 pada setiap page tenant Panel. Tentukan destination alternatif seperti invitation screen atau create-workspace flow dan letakkan di luar tenant Panel.
- Pada skema subdomain, session secara default terikat host.
SESSION_DOMAIN=.example.testmembagikan session ke subdomain; membiarkannya per-host merupakan boundary yang lebih keras antar-tenant. Tentukan keputusan ini sejak awal. - Tenancy bukan authorization. Scope menentukan row mana yang dianggap ada. Policy menentukan operasi apa yang boleh dilakukan terhadap row tersebut. Keduanya tetap diperlukan.
Lihat juga
- Tenancy Concepts
- Tenant Resolver, Tenant URLs
- The PanelTenant Contract, HasPanelTenants
- Resource Tenant Scoping
- Single Database Tenancy, Database per Tenant
- Using with stancl/tenancy
- Tenant Switcher
- Queues and Tenant Context
- Tenancy Security Checklist
- Testing Tenancy
- Tenancy Scope Leaks
- Locking a Panel Down, App Panel Example