Satu Database per Tenant
Satu database untuk setiap tenant, dengan connection itu sendiri sebagai batas isolasi. Tidak ada tenant_id yang bisa terlupakan dalam sebuah query karena memang tidak ada tenant_id. Pendekatan ini menghilangkan satu kelas bug sekaligus memindahkan kompleksitas ke provisioning, migration, dan operasional banyak database. Gunakan pola ini ketika jumlah tenant relatif sedikit tetapi besar, ketika isolasi harus dapat dibuktikan dengan jelas, atau ketika data pelanggan harus dapat diekspor maupun dihapus sebagai satu unit.
Apa yang dilakukan panel, dan apa yang tidak
PandaBear tidak pernah mengganti database connection. Itu adalah tanggung jawab stancl/tenancy, dan prosesnya berjalan sebelum middleware milik panel. Kontribusi PandaBear adalah bagian yang tidak dapat dijawab hanya oleh connection:
| Tanggung jawab | Pemilik |
|---|---|
| Membuat database dan menjalankan migration-nya | stancl/tenancy |
| Mengganti connection untuk request ini | middleware identifikasi stancl/tenancy |
| Menentukan tenant untuk request ini dalam bentuk model | Panel::tenant() |
| Menentukan apakah user boleh masuk ke tenant tersebut | HasPanelTenants::canAccessPanelTenant() |
| Switcher dan URL tenant | Panel::tenantUrlUsing() |
| Scoping resource | tidak ada — connection itu sendiri adalah scope |
Sebuah tenant panel
<?php
declare(strict_types=1);
namespace App\Panels\App;
use App\Models\Tenant;
use PandaPanel\Core\Panel;
use PandaPanel\Core\PanelProvider;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Middleware\PreventAccessFromCentralDomains;
final class AppPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->path('app')
// Replaces the base stack, which is what puts the tenancy
// middleware ahead of ResolvePanel. Include `web` yourself.
->middleware([
'web',
InitializeTenancyByDomain::class,
PreventAccessFromCentralDomains::class,
])
->auth()
// By the time this runs the connection is already the tenant's,
// so the resolver reads the identified tenant back.
->tenant(Tenant::class, static fn (): ?Tenant => tenant())
->tenantUrlUsing(
static fn (Tenant $tenant, Panel $panel): string
=> "https://{$tenant->domains->first()?->domain}/{$panel->getPath()}",
);
}
}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
middleware() menggantikan base stack. Inilah yang memungkinkan middleware identifikasi tenancy ditempatkan sebelum PandaPanel\Http\Middleware\ResolvePanel — predicate canAccess milik panel membaca user, sementara user yang digunakan bergantung pada database mana yang sedang aktif. Periksa urutannya setidaknya sekali:
php artisan route:list --path=appMiddleware tenancy harus muncul sebelum PandaPanel\Http\Middleware\ResolvePanel.
Scoping resource: tidak perlu melakukan apa pun
final class DocumentResource extends Resource
{
protected static string $model = Document::class;
// No $tenantRelationship. There is no column to scope by.
}2
3
4
5
6
Resource::query() berjalan pada connection apa pun yang sedang aktif, dan dalam pola ini connection aktif adalah connection milik tenant. Menentukan tenantRelationship() justru akan membangun whereHas terhadap tabel tenants yang tidak ada di dalam database tenant.
applyTenantScope() mengembalikan query tanpa perubahan untuk resource apa pun yang tidak menyebut relationship. Jadi, mendeklarasikan tenant() pada panel tidak menambah biaya scoping untuk resource-resource tersebut.
Ada dua hal yang tetap penting.
Resource::query() tetap menjadi satu-satunya pintu scope. Semua fitur framework melewatinya — list, record lookup, action, global search, export. Jika masih diperlukan penyempitan tambahan di dalam tenant, tempatnya tetap di sana dan bukan di tempat lain.
Resource central membutuhkan connection yang eksplisit. Panel pada central domain yang mengelola tenant membaca model yang tidak berada di database tenant:
<?php
declare(strict_types=1);
namespace App\Models;
use Stancl\Tenancy\Database\Models\Tenant as BaseTenant;
final class Tenant extends BaseTenant
{
/** Whatever config/tenancy.php names as the central connection. */
protected $connection = 'central';
}2
3
4
5
6
7
8
9
10
11
12
13
Tanpa pengaturan tersebut, membuka daftar tenant dari dalam tenant context akan mencoba membaca tabel tenants dari database tenant itu sendiri — tabel tersebut tidak ada, dan pesan error biasanya tidak menjelaskan penyebab arsitekturalnya.
Mengapa tetap perlu mendeklarasikan tenant()
Walaupun tidak ada resource scope yang perlu diterapkan, deklarasi tenancy pada panel tetap memiliki fungsi penting:
- Pemeriksaan akses per request.
ResolveTenantmemanggilcanAccessPanelTenant()pada setiap request. Connection switching membuktikan database mana yang aktif; hal itu tidak membuktikan bahwa user ini berhak berada di tenant tersebut. - Tenant switcher.
Tenancy::availableTo()danPanel::getTenantUrl()memungkinkan user berpindah tenant. Tenancy::current()untuk kode aplikasi Anda sendiri — misalnya heading yang menampilkan nama tenant, widget, atau audit entry.
Jika panel tidak mendeklarasikan tenant(), seluruh kemampuan di atas tidak tersedia dan ResolveTenant tidak pernah didaftarkan. Itu tetap merupakan pilihan yang valid untuk arsitektur di mana satu user hanya memiliki satu tenant dan tidak ada kebutuhan untuk berpindah tenant.
Di mana user disimpan
Dalam arsitektur database-per-tenant, user biasanya berada di dalam database tenant. Konsekuensinya, "satu user, banyak tenant" berarti terdapat beberapa row user yang kebetulan memakai alamat email yang sama. Jika Anda membutuhkan satu akun yang benar-benar sama untuk banyak tenant, tabel user harus berada di central database sementara database tenant menyimpan data lainnya — ini adalah keputusan desain nyata, bukan sekadar setting.
Keputusan ini juga menentukan apa yang dapat dijawab oleh getPanelTenants(). User yang berada di database tenant tidak dapat membaca daftar tenant secara langsung, sehingga switcher memerlukan membership yang berada di central database:
/** @return Collection<int, Model> */
public function getPanelTenants(Panel $panel): Collection
{
return Tenant::query() // on the central connection
->whereIn('id', Membership::query()
->where('email', $this->email)
->pluck('tenant_id'))
->get();
}2
3
4
5
6
7
8
9
Migration
Pisahkan migration berdasarkan pemilik datanya:
| Migration | Lokasi |
|---|---|
users dan tabel domain aplikasi | database/migrations/tenant |
tenants, domains, plan, billing | database/migrations |
Dua migration bawaan package ini termasuk migration tenant:
create_notifications_table— notification dimiliki user, dan user bersifat per tenant.add_email_two_factor_to_users_table— menambahkan column padausers.
php artisan tenants:migrate
php artisan tenants:seed2
Tabel jobs dan cache merupakan keputusan arsitektur. Menaruhnya di central database lebih sederhana dan cukup memakai satu worker; menyimpannya per tenant mencegah satu tenant memenuhi queue milik tenant lain.
Pemisahan cache bukan opsional
Panel menyimpan state per-user di cache dengan key berdasarkan user id — sementara user dengan id 1 dapat ada di setiap database tenant. PandaPanel\Auth\EmailCodeChallenge juga menggunakan user id sebagai key untuk kode second-factor yang dikirim lewat email; tanpa pemisahan cache, kode milik satu tenant dapat memvalidasi challenge tenant lain. Prinsip yang sama berlaku untuk table state, widget filter, dan state lain yang memakai id yang hanya unik di dalam satu tenant.
// config/tenancy.php
'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
Catatan
- Connection adalah batas isolasi, sedangkan
Tenancyadalah label konteksnya. Keduanya independen. Resolver yang mengembalikan model tenant yang salah tidak akan mengganti connection, dan mengganti connection tidak otomatis mengikat tenant keTenancy. - Panel didaftarkan saat boot. Semua tenant menggunakan kumpulan panel yang sama; panel tidak dapat didaftarkan satu per tenant. Perbedaan perilaku per tenant diekspresikan melalui
Resource::canViewAny()danPanel::canAccess(). - Central panel memerlukan
->domain(). Tanpanya,admin.example.testdapat diidentifikasi sebagai tenant bernamaadmin. - Queued work tidak membawa tenant binding dari panel. Connection dipulihkan oleh
QueueTenancyBootstrapper;Tenancy::current()tidak. Ini hanya berdampak pada kode yang memang membaca konteks tersebut — lihat Queue. php artisan panel:cachemenyimpan cache discovery panel, bukan data tenant. Aman digunakan pada arsitektur ini dan berlaku per deployment, bukan per tenant.