Menggunakan stancl/tenancy
stancl/tenancy menangani bagian yang memang sengaja tidak ditangani PandaBear: membuat database tenant, mengganti connection, mempartisi cache dan filesystem, serta menentukan arti sebuah subdomain. PandaBear menangani bagian yang tidak dapat dijawab oleh package tersebut: request panel ini ditujukan ke tenant mana, apakah user boleh masuk ke tenant tersebut, dan bagaimana scoped resource dipersempit. Halaman ini menjelaskan cara menggabungkan keduanya.
stancl/tenancy bukan dependency dari package ini. Tidak ada langkah di halaman ini yang diinstal otomatis — composer.json membutuhkan PHP ^8.2, laravel/framework ^12.0|^13.0, inertiajs/inertia-laravel ^3.0, laravel/fortify, dan symfony/finder, tanpa dependency tenancy. Extension point yang disebutkan di bawah memang tersedia; proses instalasi tetap harus Anda lakukan karena menambah dependency dan mengubah schema database.
Instalasi
composer require stancl/tenancy
php artisan tenancy:install
php artisan migrate2
3
tenancy:install mem-publish config/tenancy.php, TenancyServiceProvider, migration tenants/domains, serta directory database/migrations/tenant. Daftarkan provider tersebut di bootstrap/providers.php.
// config/tenancy.php
'tenant_model' => App\Models\Tenant::class,
'central_domains' => [
'example.test', // where tenants are created and billed
'admin.example.test', // and where your admin panel lives
],
'bootstrappers' => [
Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper::class,
Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper::class,
Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper::class,
Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class,
],2
3
4
5
6
7
8
9
10
11
12
13
14
15
Cache bootstrapper bukan opsional untuk framework ini. Panel menyimpan state per-user di cache, sedangkan user dengan id 1 dapat ada di setiap database tenant. PandaPanel\Auth\EmailCodeChallenge menggunakan user id sebagai key untuk kode second-factor yang dikirim melalui email; tanpa pemisahan cache, kode dari satu tenant dapat memenuhi challenge tenant lain. Prinsip yang sama berlaku untuk table state, widget filter, dan state lain yang menggunakan id yang hanya unik di dalam satu tenant.
Model tenant
<?php
declare(strict_types=1);
namespace App\Models;
use PandaPanel\Contracts\PanelTenant;
use Stancl\Tenancy\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\Concerns\HasDatabase;
use Stancl\Tenancy\Database\Concerns\HasDomains;
use Stancl\Tenancy\Database\Models\Tenant as BaseTenant;
final class Tenant extends BaseTenant implements TenantWithDatabase, PanelTenant
{
use HasDatabase;
use HasDomains;
/**
* Columns that are real columns rather than JSON in `data`.
*
* The panel reads a name for the switcher, so it is worth promoting out
* of the JSON blob — a switcher that has to decode JSON to draw a label
* is a switcher that cannot be sorted by it.
*
* @return array<int, string>
*/
public static function getCustomColumns(): array
{
return ['id', 'name', 'plan', 'trial_ends_at'];
}
public function getTenantKey(): int|string
{
return (string) $this->getKey();
}
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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
Tambahkan column-column tersebut melalui migration pada central connection.
PandaPanel\Contracts\PanelTenant bersifat opsional — tanpanya, Tenancy menggunakan primary key dan atribut name sebagai fallback, yang sebenarnya sudah dapat dipenuhi tenant bawaan stancl jika name dipromosikan menjadi column biasa. Mengimplementasikan contract membuat perilakunya eksplisit dan memungkinkan getTenantKey() mengembalikan string id secara konsisten tanpa cast pada setiap perbandingan. Lihat PanelTenant.
Routing: panel mana central dan panel mana tenant
Ini adalah bagian paling penting dan bagian yang paling sering salah pada instalasi sederhana.
PandaPanel\Routing\PanelRouteRegistrar mendaftarkan route untuk semua panel saat boot menggunakan middleware stack milik panel, lalu menambahkan ResolvePanel, RequireTwoFactor, RequireEmailCode, dan — untuk panel dengan tenancy — ResolveTenant. Middleware identifikasi milik stancl/tenancy harus berjalan sebelum semuanya, termasuk ResolvePanel: predicate canAccess milik panel membaca user, dan user yang dibaca bergantung pada database mana yang sudah aktif.
use PandaPanel\Core\Panel;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Middleware\PreventAccessFromCentralDomains;
// A tenant panel: identified by subdomain, on the tenant's database.
Panel::make('app')
->path('app')
->middleware([
'web',
InitializeTenancyByDomain::class,
PreventAccessFromCentralDomains::class,
])
->auth()
->tenant(Tenant::class, static fn (): ?Tenant => tenant());
// A central panel: no tenancy middleware, and pinned to a central host.
Panel::make('admin')
->path('admin')
->domain('admin.example.test')
->auth();2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Panel::middleware() menggantikan base stack. Ini justru yang memungkinkan middleware tenancy ditempatkan sebelum ResolvePanel, karena registrar selalu menambahkan middleware miliknya paling akhir. Panel::domain() menjaga central panel agar tidak tersedia pada tenant subdomain; tanpanya, admin. dapat diidentifikasi sebagai tenant bernama admin.
Periksa urutannya sekali, lalu ulangi setiap kali middleware stack panel berubah:
php artisan route:list --path=appMiddleware tenancy harus muncul sebelum PandaPanel\Http\Middleware\ResolvePanel.
Route didaftarkan saat boot, tenant di-resolve per request
PanelRouteRegistrar::registerAll() berjalan saat service provider package melakukan boot. Itu tidak menjadi masalah — route bersifat statis sedangkan identifikasi tenant terjadi per request — tetapi berarti panel tidak dapat didaftarkan per tenant. Semua tenant menggunakan panel yang sama. Jika resource yang tersedia berbeda per tenant, ekspresikan perbedaannya dengan Resource::canViewAny() dan Panel::canAccess(), bukan dengan mendaftarkan panel berbeda.
Resolver
Saat ResolveTenant berjalan, stancl/tenancy sudah mengidentifikasi tenant dan mengganti connection. Karena itu resolver hanya membaca tenant tersebut kembali:
$panel->tenant(Tenant::class, static fn (): ?Tenant => tenant());tenant() pada contoh ini adalah helper milik stancl/tenancy, bukan milik package ini. Helper global milik PandaBear adalah panel(); tidak ada helper tenant() di src/Support/helpers.php, sehingga keduanya tidak bertabrakan.
Nilai yang dikembalikan resolver di-type-guard terhadap model tenant yang dideklarasikan. Helper yang mengembalikan null pada central domain menghasilkan 404, bukan request yang setengah ter-scope.
Membership dan switcher
ResolveTenant tetap memanggil HasPanelTenants::canAccessPanelTenant() pada setiap request. Connection switching hanya membuktikan database mana yang aktif; itu tidak membuktikan user ini berhak masuk ke tenant tersebut.
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Contracts\HasPanelTenants;
use PandaPanel\Core\Panel;
final class User extends Authenticatable implements HasPanelTenants
{
/** @return Collection<int, Model> */
public function getPanelTenants(Panel $panel): Collection
{
// Central connection: a user inside a tenant database cannot see the
// tenant list without one.
return Tenant::query()
->whereIn('id', Membership::query()->where('email', $this->email)->pluck('tenant_id'))
->get();
}
public function canAccessPanelTenant(Model $tenant, Panel $panel): bool
{
return Membership::query()
->where('email', $this->email)
->where('tenant_id', $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
Jika user berada di database tenant, kemampuan user mencapai panel tersebut sudah membuktikan bahwa row user ada di tenant itu. Pemeriksaan di atas menjaga switcher tetap jujur dan mencegah central-connection user model memasuki tenant yang tidak pernah mengundangnya.
$panel->tenantUrlUsing(
static fn (Tenant $tenant, Panel $panel): string
=> "https://{$tenant->domains->first()?->domain}/{$panel->getPath()}",
);2
3
4
Scoping resource
Pada arsitektur database-per-tenant, tidak ada yang perlu dilakukan. Biarkan $tenantRelationship bernilai null; Resource::query() berjalan menggunakan current connection dan current connection tersebut sudah merupakan database tenant. Lihat Satu Database per Tenant.
Jika Anda memilih satu database bersama, scoping menjadi tanggung jawab Anda dan $tenantRelationship adalah tempat untuk mendeklarasikannya. Lihat Tenancy Satu Database.
Session
Identifikasi berdasarkan subdomain berarti cookie dan session bersifat per-host secara default. 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 sumber support ticket setiap hari. Putuskan sebelum membangun switcher, bukan sesudahnya.
Migration
php artisan tenants:migrate
php artisan tenants:seed2
Migration yang sudah ada perlu dipisahkan. Semua yang dimiliki tenant — users, notifications, dan tabel domain — pindah ke database/migrations/tenant. Data central — tenants, domains, plan — tetap di database/migrations.
Dua migration bawaan package ini termasuk tenant migration:
create_notifications_table— notification dimiliki user, sedangkan user bersifat per tenant.add_email_two_factor_to_users_table— menambahkan column padausers.
Queued work
QueueTenancyBootstrapper memasukkan job kembali ke tenant context ketika job dijalankan, untuk job yang di-dispatch di dalam tenant context — sehingga connection yang digunakan benar. Bootstrapper tersebut tidak memulihkan tenant binding milik PandaBear sendiri: Tenancy::current() bernilai null di dalam job karena PandaPanel\Support\PanelContext adalah scoped binding dan queue worker membuang scoped instance di antara job. Hal ini hanya penting bagi kode yang membaca binding tersebut. Lihat Queue dan Konteks Tenant.
Billing dan trial
Panel::canAccess() hanya menjawab ya atau tidak, sehingga kegagalannya menghasilkan 403 — bukan perilaku yang tepat untuk trial kedaluwarsa, di mana user seharusnya diarahkan ke billing:
// A rule about the panel — a 403 when it fails.
$panel->canAccess(static fn (?Authenticatable $user): bool => tenant()?->subscribed() === true);2
Untuk redirect, gunakan middleware pada stack panel dengan pola seperti PandaPanel\Http\Middleware\RequireTwoFactor — middleware menahan semua halaman sampai suatu kondisi terpenuhi dan mengarahkan user ke satu halaman tempat masalah dapat diselesaikan:
$panel->middleware([
'web',
InitializeTenancyByDomain::class,
PreventAccessFromCentralDomains::class,
EnsureTenantIsSubscribed::class, // redirects to billing, not 403
]);2
3
4
5
6
Pastikan halaman tujuan redirect dikecualikan dari middleware tersebut, atau Anda membuat redirect loop.
Komponen sisi panel
| Kebutuhan | Tempat implementasi |
|---|---|
| Profil tenant, link billing | Panel::userMenuItems() |
| Halaman profil tenant | standalone page: php artisan make:panel-page TenantProfile --panel=App |
| Registrasi tenant | page pada central panel — tenant yang belum ada belum memiliki subdomain tempat proses create dapat berlangsung |
| Pencarian tenant | Panel::globalSearch() terhadap central Tenant resource pada central panel |
| Tenant default setelah login | routing milik aplikasi Anda; tidak ada framework hook karena ini menentukan tujuan dari login tanpa tenant |
$panel->userMenuItems([
['label' => 'Team settings', 'url' => '/app/tenant', 'icon' => 'settings'],
['label' => 'Billing', 'url' => '/app/billing', 'icon' => 'receipt'],
]);2
3
4
Urutan implementasi yang disarankan
- Jawab tiga pertanyaan terlebih dahulu — satu database atau banyak, bagaimana tenant diidentifikasi, dan apakah user hanya memiliki satu tenant atau banyak — lalu dokumentasikan jawabannya.
- Install dan konfigurasi tenancy. Pastikan
tenants:migratebekerja sebelum menyentuh panel. - Atur
SESSION_DOMAINdan pastikan login melalui subdomain bekerja tanpa melibatkan panel terlebih dahulu. - Tambahkan middleware tenancy pada satu panel lalu periksa
route:list. - Pindahkan tenant migration.
- Baru lanjut ke integrasi sisi panel:
tenant(),tenantUrlUsing(),HasPanelTenants, menu, serta billing gate jika diperlukan.
Langkah 3 dan 4 adalah titik di mana kesalahan masih murah untuk diperbaiki tetapi akan sangat sulit terlihat jika dibiarkan. Panel yang di-resolve sebelum tenant akan membaca central database dan dapat terlihat berfungsi normal sampai dua tenant kebetulan memiliki row dengan id yang sama.
Catatan
- Uji kedua sisi secara terpisah. Test suite panel membangun panel inline dan mendaftarkan route saat runtime; pola tersebut tetap dapat digunakan untuk tenant panel. Namun test yang benar-benar masuk ke tenant database membutuhkan database tersebut tersedia sehingga lebih lambat. Uji perilaku panel dengan fixture panel tanpa tenancy middleware, dan perilaku tenancy di file test tersendiri. Hanya sedikit test yang benar-benar membutuhkan keduanya sekaligus.
- Helper
tenant()milikstancl/tenancydan helperpanel()milik package ini tidak berkaitan. Keduanya global function, tetapi tidak saling menimpa. PreventAccessFromCentralDomainsdanPanel::domain()menyelesaikan dua sisi dari masalah yang sama. Yang pertama menjaga tenant panel agar tidak tersedia di central host; yang kedua menjaga central panel agar tidak tersedia di tenant host.- Tidak ada kode dalam package ini yang membaca
config/tenancy.php. Satu-satunya bridge adalah resolver yang Anda tulis.