URL Tenant
URL tenant adalah sisi kebalikan dari resolver. Resolver mengubah request menjadi tenant; Panel::tenantUrlUsing() mengubah tenant kembali menjadi request yang dapat dinavigasi, sehingga switcher memiliki tujuan. Hanya pembuat resolver yang mengetahui bagaimana tenant dialamatkan — melalui subdomain, segmen path, atau satu tenant per user — sehingga hanya aplikasi yang dapat membalik proses tersebut. Framework sengaja tidak menebak.
Mendeklarasikan URL builder
use App\Models\Team;
use PandaPanel\Core\Panel;
$panel->tenantUrlUsing(
static fn (Team $team, Panel $panel): string => "https://{$team->slug}.example.com/{$panel->getPath()}",
);2
3
4
5
6
panel('app')->getTenantUrl($team); // 'https://acme.example.com/app'URL tersebut menjadi tujuan setiap entry di tenant switcher. Tanpa builder, getTenantUrl() mengembalikan null untuk semua tenant dan switcher tidak dirender.
API
/** @param Closure(Model, self): string $url */
public function tenantUrlUsing(Closure $url): self
public function getTenantUrl(Model $tenant): ?string2
3
4
| Method | Signature | Catatan |
|---|---|---|
tenantUrlUsing | tenantUrlUsing(Closure $url): self | Closure menerima model tenant dan panel |
getTenantUrl | getTenantUrl(Model $tenant): ?string | null jika tidak ada builder yang dideklarasikan |
public function getTenantUrl(Model $tenant): ?string
{
return $this->tenantUrl === null ? null : ($this->tenantUrl)($tenant, $this);
}2
3
4
Argumen kedua closure adalah panel, sehingga satu builder dapat ditulis sekali lalu digunakan kembali pada beberapa panel:
$urlForTenant = static fn (Team $team, Panel $panel): string
=> "https://{$team->slug}.example.com/{$panel->getPath()}";
$appPanel->tenantUrlUsing($urlForTenant); // .../app
$reportsPanel->tenantUrlUsing($urlForTenant); // .../reports2
3
4
5
Tiga pola addressing
Subdomain
Tenant direpresentasikan oleh host. URL harus absolute karena switcher berpindah ke origin lain.
$panel->tenantUrlUsing(
static fn (Team $team, Panel $panel): string
=> "https://{$team->slug}.example.com/{$panel->getPath()}",
);2
3
4
Kunci panel ke pola host agar router hanya mencocokkan tenant subdomain, lalu identifikasi tenant dari domain parameter milik route:
use Illuminate\Http\Request;
$panel
->domain('{team}.example.com')
->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
String domain diteruskan ke router tanpa perubahan, sehingga route parameter di dalamnya bekerja persis seperti pada Route::domain().
Segmen path
Tenant menjadi bagian dari prefix panel.
$panel
->path('app/{team}')
->tenant(
Team::class,
static fn (Request $request): ?Team => Team::query()
->where('slug', $request->route('team'))
->first(),
)
->tenantUrlUsing(
static fn (Team $team): string => "/app/{$team->slug}",
);2
3
4
5
6
7
8
9
10
11
Path digunakan sebagai route group prefix tanpa perubahan, sehingga {team} menjadi route parameter biasa pada setiap route yang didaftarkan panel.
Query parameter
Ini adalah skema paling sederhana yang tetap berfungsi dan digunakan oleh test suite framework karena tidak membutuhkan konfigurasi host maupun route tambahan:
$panel
->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
Resolver dan switcher memang bekerja, tetapi perhatikan warning di bawah: URL yang dihasilkan framework tidak membawa query string, sehingga link di dalam panel akan kehilangan informasi tenant.
Membuat seluruh URL panel membawa tenant
Resource::url() dan Page::url() membangun semua link yang dirender panel, dan keduanya menggunakan route name Laravel:
public static function url(
string $page = 'index',
Model|int|string|null $record = null,
Panel|string|null $panel = null,
Model|int|string|null $parent = null,
): string2
3
4
5
6
return route(static::routeName($page, $resolved), $parameters, absolute: false);Array $parameters memuat record dan, untuk nested resource, parent record. Array tersebut tidak pernah memuat tenant — framework tidak mengetahui nama route parameter yang Anda pilih, dan menambahkan satu secara otomatis berarti menebak.
Untuk segmen path atau subdomain, gunakan mekanisme native Laravel URL::defaults(). Set nilainya berdasarkan route parameter melalui middleware dalam stack panel, sehingga setiap pemanggilan route() berikutnya otomatis mengisi parameter yang diperlukan:
<?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 DefaultTenantUrlParameter
{
public function handle(Request $request, Closure $next): Response
{
$team = $request->route('team');
if (is_string($team)) {
URL::defaults(['team' => $team]);
}
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
$panel->middleware(['web', DefaultTenantUrlParameter::class]);Panel::middleware() menggantikan base stack, sehingga web harus dicantumkan sendiri. Stack milik panel berjalan sebelum ResolvePanel dan ResolveTenant; karena itu middleware ini membaca route parameter, bukan Tenancy::current() — pada tahap tersebut tenant memang belum di-bind.
Setelah konfigurasi tersebut:
DocumentResource::url('edit', $document); // '/app/acme/documents/12/edit'Untuk query parameter, tidak ada mekanisme yang setara. URL::defaults() hanya mengisi route parameter, sedangkan route() hanya menambahkan query string untuk argumen yang dikirim secara eksplisit. Anda dapat menambahkan tenant pada setiap call site secara manual, tetapi pendekatan yang jauh lebih baik adalah memindahkan tenant ke segmen path atau subdomain.
Session lintas-subdomain
Subdomain tenancy membawa konsekuensi yang sebaiknya diputuskan sejak awal: cookie dan session secara default hanya berlaku pada satu host. User yang login di acme.example.test tidak otomatis login di beta.example.test kecuali Anda menetapkan:
SESSION_DOMAIN=.example.testItu bisa menjadi hard boundary yang memang Anda inginkan, atau penyebab support ticket setiap hari. Jauh lebih mudah menentukan keputusan tersebut sekarang daripada mengubah perilakunya setelah aplikasi berjalan.
Catatan
- Tanpa builder, tidak ada switcher.
canSwitchTenantsmembutuhkan setidaknya satu entry denganurlnon-null. Panel dengan tenancy tetapi tanpatenantUrlUsing()tetap dapat melakukan resolve, authorization, dan scoping dengan benar; hanya tidak menyediakan cara berpindah tenant. - Gunakan absolute URL untuk perpindahan antar-host. Switcher sengaja merender
<a>biasa agar perpindahan cross-origin bekerja. Relative path akan tetap berada di host saat ini dan membawa user kembali ke tenant yang sedang aktif. - Builder tidak dipanggil untuk tenant yang tidak boleh diakses user. Builder berjalan terhadap
Tenancy::availableTo(), yang sudah difilter oleh model user. getTenantUrl()dipanggil satu kali per tenant pada setiap render panel, di dalam shared-prop closure. Buat closure hanya melakukan string building; jangan menaruh query di dalamnya.- Cluster mengubah path, bukan route name. URL yang dibangun dari route name tetap bekerja ketika resource masuk ke cluster. Ini alasan tambahan untuk membangun tenant URL dari
$panel->getPath()dan tidak meng-hard-code/app. Panel::path()hanya memangkas slash.->path('app/{team}')diteruskan ke route group sebagai prefix tanpa perubahan, sehingga parameter tersebut harus konsisten dengan route danURL::defaults()Anda.