Tenant Resolver
Resolver adalah closure yang menjawab pertanyaan "request ini ditujukan untuk tenant yang mana". Ini adalah bagian tenancy yang sengaja tidak ditebak oleh framework, karena setiap default yang tampak masuk akal — subdomain, route parameter, atau satu-satunya team milik user — bisa benar pada satu arsitektur tetapi menjadi kebocoran data yang diam-diam pada arsitektur lain. Anda menuliskannya satu kali pada panel, lalu PandaPanel\Http\Middleware\ResolveTenant memanggilnya satu kali untuk setiap request sebelum controller apa pun berjalan.
Mendeklarasikan resolver
<?php
declare(strict_types=1);
namespace App\Panels\App;
use App\Models\Team;
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(
Team::class,
static fn (Request $request): ?Team => Team::query()
->where('slug', $request->route('team'))
->first(),
);
}
}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
Ada dua argumen. Argumen pertama adalah class model tenant; argumen kedua adalah closure resolver. Mendeklarasikan keduanya adalah tindakan yang mengaktifkan tenancy pada panel — tidak ada mekanisme lain yang melakukannya.
Panel::tenant()
/**
* @param class-string<Model> $model
* @param Closure(Request, ?Authenticatable): ?Model $resolver
*/
public function tenant(string $model, Closure $resolver): self2
3
4
5
| Argumen | Tipe | Arti |
|---|---|---|
$model | class-string<Illuminate\Database\Eloquent\Model> | Model yang dianggap sebagai tenant. Juga menjadi type guard untuk nilai yang dikembalikan resolver. |
$resolver | Closure(Request, ?Authenticatable): ?Model | Cara menemukan tenant. Menerima request dan user yang terautentikasi. |
Memanggil tenant() memiliki empat efek yang perlu dipahami secara tepat:
- Setiap route group yang didaftarkan panel mendapatkan
ResolveTenant, yang mengidentifikasi tenant, memeriksa akses user, lalu melakukan bind sebelum controller berjalan. PandaPanel\Tenancy\Tenancy::current()tersedia untuk sisa lifecycle request.- Resource yang mendeklarasikan
tenantRelationship()otomatis di-scope, sedangkan resource yang tidak mendeklarasikannya dibiarkan apa adanya. - Daftar tenant untuk switcher dibagikan ke frontend.
Method ini tidak mengganti connection, mempartisi cache, atau membaca subdomain.
Tiga pola resolver
Tugas resolver hanya satu: mengembalikan model tenant atau null. Dari mana model tersebut diperoleh sepenuhnya merupakan keputusan aplikasi Anda.
Database per tenant, diidentifikasi melalui subdomain. stancl/tenancy sudah mengidentifikasi tenant dan mengganti connection sebelum resolver ini berjalan, sehingga resolver cukup membaca tenant yang sudah teridentifikasi:
use App\Models\Tenant;
$panel->tenant(Tenant::class, static fn (): ?Tenant => tenant());2
3
Satu database, tenant berada pada segmen path. Route parameter menjadi identifier, lalu resolver melakukan lookup:
use App\Models\Team;
use Illuminate\Http\Request;
$panel->tenant(
Team::class,
static fn (Request $request): ?Team => Team::query()
->where('slug', $request->route('team'))
->first(),
);2
3
4
5
6
7
8
9
Satu tenant per user, tanpa identifier di URL. Argumen kedua resolver menyediakan authenticated user:
use App\Models\Workspace;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
$panel->tenant(
Workspace::class,
static fn (Request $request, ?Authenticatable $user): ?Workspace => $user?->workspace,
);2
3
4
5
6
7
8
Query parameter juga dapat digunakan dan merupakan pola yang dipakai test suite framework karena dapat menguji code path yang sama tanpa konfigurasi host atau route khusus:
$panel->tenant(
Workspace::class,
static fn (Request $request): ?Workspace => Workspace::query()
->find($request->query('workspace')),
);2
3
4
5
Membaca kembali deklarasi tenancy
public function hasTenancy(): bool
public function getTenantModel(): ?string // class-string<Model>|null
public function resolveTenant(Request $request, ?Authenticatable $user): ?Model2
3
use PandaPanel\Core\Panel;
panel('app')->hasTenancy(); // true
panel('app')->getTenantModel(); // 'App\Models\Team'2
3
4
resolveTenant() dipanggil oleh ResolveTenant dan tidak digunakan di tempat lain. Di sinilah type guard diterapkan:
public function resolveTenant(Request $request, ?Authenticatable $user): ?Model
{
$model = $this->tenantModel;
$resolver = $this->tenantResolver;
if ($resolver === null || $model === null) {
return null;
}
$tenant = $resolver($request, $user);
return $tenant instanceof $model ? $tenant : null;
}2
3
4
5
6
7
8
9
10
11
12
13
Resolver yang mengembalikan sesuatu selain instance dari model yang dideklarasikan dianggap sebagai tidak ada tenant, sehingga menghasilkan 404. Tanpa guard ini, resolver yang salah dan tanpa sengaja mengembalikan user bisa membuat query terlihat seperti berhasil di-scope berdasarkan user id, padahal tenant-nya salah.
Apa yang dilakukan middleware terhadap hasil resolver
PandaPanel\Http\Middleware\ResolveTenant:
public function handle(Request $request, Closure $next, string $panelId): Response$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
| Hasil resolver | Respons |
|---|---|
| Model dari class yang benar, dan user boleh mengaksesnya | request diteruskan dan tenant di-bind |
| Model dari class yang benar, tetapi user tidak boleh mengaksesnya | 403 |
null | 404 No such tenant. |
| Nilai lain — class salah, array, string | 404 No such tenant. |
Panel id adalah parameter middleware, bukan sesuatu yang dicocokkan dari URL. Karena itu dua panel yang memakai prefix serupa tetap tidak ambigu dan route tetap dapat di-cache.
Posisi middleware dalam stack
Registrar menambahkan middleware framework setelah stack milik panel sendiri, dengan urutan berikut:
| Posisi | Middleware | Alasan |
|---|---|---|
| 1 | ResolvePanel:{id} | Mengikat panel; resolver dibaca dari panel tersebut. |
| 2 | RequireTwoFactor:{id} | |
| 3 | RequireEmailCode:{id} | |
| 4 | ResolveTenant:{id} | Berjalan setelah user diketahui dan sebelum controller dapat menjalankan query. |
Hanya panel yang memanggil tenant() yang mendapatkan entry keempat. Panel tanpa tenancy tidak membayar biaya apa pun, sedangkan panel dengan tenancy tidak bisa secara tidak sengaja melupakan middleware tersebut.
php artisan route:list --path=appMiddleware yang harus berjalan sebelum panel di-resolve — misalnya middleware identifikasi dari stancl/tenancy — ditempatkan di stack milik panel, yang menggantikan base stack:
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Middleware\PreventAccessFromCentralDomains;
$panel->middleware([
'web',
InitializeTenancyByDomain::class,
PreventAccessFromCentralDomains::class,
]);2
3
4
5
6
7
8
Mendaftarkan route secara manual
Tidak ada middleware alias untuk ResolveTenant. Route di luar group bawaan panel yang membutuhkan tenant terikat harus menyebut class middleware secara langsung dan meneruskan panel id:
use Illuminate\Support\Facades\Route;
use PandaPanel\Http\Middleware\ResolvePanel;
use PandaPanel\Http\Middleware\ResolveTenant;
Route::get('/app/report', ReportController::class)
->middleware([
'web',
'auth',
ResolvePanel::class.':app',
ResolveTenant::class.':app',
]);2
3
4
5
6
7
8
9
10
11
Tanpa middleware tersebut, tenant-scoped resource yang dibaca dari controller akan melempar PanelRegistrationException::noCurrentTenant().
Catatan
- Resolver berjalan pada setiap request panel, termasuk dashboard, search endpoint, action endpoint, dan upload endpoint. Usahakan resolver hanya membutuhkan satu indexed lookup; ini bukan tempat untuk eager-load seluruh graph milik tenant.
- Guest page milik panel tidak memanggil resolver. Login, registration, password reset, dan email verification didaftarkan hanya dengan base middleware dan
ResolvePanel, sehingga tenant tidak di-resolve sebelum user login. nulldari resolver menghasilkan 404, bukan redirect. Jika/apptanpa tenant seharusnya diarahkan ke tempat tertentu, itu adalah keputusan routing: lakukan redirect melalui middleware Anda sendiri yang ditempatkan sebelumResolveTenant, atau buat resolver memilih tenant default milik user.- Resolver tidak boleh mengasumsikan user selalu ada. Argumen kedua bertipe
?Authenticatabledan dapat bernilai null pada route panel yang bisa diakses guest. - Memanggil
tenant()dua kali mengganti kedua nilai sebelumnya. Hanya ada satu model tenant dan satu resolver per panel. - Menambahkan tenancy setelah boot tidak memengaruhi route yang sudah didaftarkan. Daftar middleware dibentuk ketika route group diregistrasikan.