Tenancy Satu Database
Satu database, dengan seluruh row milik semua tenant berada pada tabel yang sama dan sebuah foreign key menunjukkan tenant pemilik setiap row. Arsitektur ini lebih murah untuk dioperasikan, tetapi where yang terlupakan berarti kebocoran data — tepat jenis kegagalan yang hendak dicegah oleh Resource::$tenantRelationship. Gunakan pola ini ketika jumlah tenant banyak tetapi ukuran masing-masing relatif kecil, ketika aplikasi perlu melakukan query lintas-tenant, atau ketika provisioning database baru untuk setiap signup bukan sesuatu yang ingin Anda operasikan.
Contoh lengkap
Ada tiga tabel: tenant, record domain, dan membership yang menentukan user mana boleh masuk ke tenant mana.
<?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->timestamps();
// The scope is a whereHas on this column. Index it.
$table->index('workspace_id');
});
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
Model tenant
<?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'];
public function getTenantKey(): int|string
{
return (int) $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
Model user
<?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);
}
/** @return Collection<int, Model> */
public function getPanelTenants(Panel $panel): Collection
{
return $this->workspaces()->orderBy('name')->get();
}
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
Model record
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class Document extends Model
{
protected $fillable = ['title'];
/** @return BelongsTo<Workspace, $this> */
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
workspace_id sengaja tidak dimasukkan ke $fillable. Tenant id yang dapat dikirim user berarti tenant id tersebut juga dapat diubah user.
Panel
<?php
declare(strict_types=1);
namespace App\Panels\App;
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
->path('app/{workspace}')
->auth()
->tenant(
Workspace::class,
static fn (Request $request): ?Workspace => Workspace::query()
->where('slug', $request->route('workspace'))
->first(),
)
->tenantUrlUsing(
static fn (Workspace $workspace): string => "/app/{$workspace->slug}",
);
}
}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
Resource
<?php
declare(strict_types=1);
namespace App\Panels\App\Resources\Documents;
use App\Models\Document;
use PandaPanel\Resources\Resource;
final class DocumentResource extends Resource
{
protected static string $model = Document::class;
protected static ?string $tenantRelationship = 'workspace';
// table(), form(), pages() as usual
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Itulah seluruh arsitekturnya. GET /app/acme/documents hanya menampilkan dokumen milik Acme, GET /app/beta/documents hanya menampilkan dokumen milik Beta, dan user yang bukan anggota keduanya mendapatkan 403.
Menetapkan ownership saat create
Scope hanya mempersempit read. Tidak ada mekanisme otomatis yang menulis workspace_id, dan nilainya tidak boleh berasal dari form. Observer adalah tempat yang tepat karena berjalan untuk semua jalur write — create page, seeder, import, maupun console command:
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Models\Document;
use PandaPanel\Tenancy\Tenancy;
final class DocumentObserver
{
public function creating(Document $document): void
{
$document->workspace_id ??= Tenancy::require()->getKey();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use App\Models\Document;
use App\Observers\DocumentObserver;
Document::observe(DocumentObserver::class);2
3
4
Gunakan Tenancy::require(), bukan Tenancy::current(): write tanpa tenant yang terikat adalah bug, dan row dengan owner null akan langsung tidak terlihat oleh seluruh scoped read setelahnya — menghasilkan record yang tampak menghilang alih-alih error yang jelas.
Seeding atau backfill di luar request dapat masuk ke tenant context melalui Tenancy::for():
Tenancy::for($workspace, static function () use ($rows): void {
foreach ($rows as $row) {
Document::query()->create($row);
}
});2
3
4
5
Membuat tenant menjadi bagian dari setiap URL
Jika tenant berada pada segmen path, Resource::url() membutuhkan route parameter tersebut terisi. Laravel menyediakan URL::defaults() untuk kebutuhan ini; set nilainya melalui middleware dalam stack milik panel:
<?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
$panel->middleware(['web', DefaultWorkspaceUrlParameter::class]);middleware() menggantikan base stack, sehingga web harus dicantumkan kembali. Lihat URL Tenant.
Tabel yang tidak dimiliki tenant mana pun
Plan, negara, atau feature flag dapat dibaca sama oleh seluruh tenant. Jangan deklarasikan relationship dan resource tersebut tidak akan di-scope.
final class PlanResource extends Resource
{
protected static string $model = Plan::class;
// No $tenantRelationship.
}2
3
4
5
6
Relationship yang lebih dalam
Record yang berjarak dua hop dari tenant dapat di-scope melalui relationship yang mencakup kedua hop karena mekanismenya menggunakan whereHas terhadap relationship apa pun yang Anda deklarasikan:
/** @return HasOneThrough<Workspace, Document, $this> */
public function workspace(): HasOneThrough
{
return $this->hasOneThrough(
Workspace::class,
Document::class,
'id', // documents.id
'id', // workspaces.id
'document_id', // comments.document_id
'workspace_id', // documents.workspace_id
);
}2
3
4
5
6
7
8
9
10
11
12
Menambahkan workspace_id terdenormalisasi pada child lalu memakai belongsTo biasa biasanya lebih cepat dan selalu lebih sederhana untuk di-index. Keduanya valid; pilih berdasarkan karakteristik setiap tabel.
Fitur lain yang membaca record
Fitur-fitur berikut tidak membutuhkan kode tenancy tambahan karena semuanya memulai query dari Resource::query():
| Fitur | Mengapa tetap ter-scope |
|---|---|
| List, filter, search, sorting | TableQuery memberi constraint pada query() |
| Record page, edit, delete | findRecord() mempersempit query() |
| Row action, bulk action, table action | action endpoint me-resolve record melalui findRecord()/findRecords() |
| Global search | GlobalSearch memulai dari query() |
| Export | RunPanelExport membangun ulang melalui query() — tetapi lihat Queue |
| Relation manager, nested resource | parent terlebih dahulu ditemukan melalui scoped query |
Yang tidak tercakup otomatis: field Select yang mengambil option langsung dari model, widget yang menjalankan query sendiri, atau controller aplikasi Anda. Semua itu membaca model secara langsung, bukan resource. Scope secara eksplisit menggunakan Tenancy::key() atau Tenancy::require().
use PandaPanel\Forms\Components\Select;
use PandaPanel\Tenancy\Tenancy;
Select::make('document_id')
->options(fn (): array => Document::query()
->where('workspace_id', Tenancy::require()->getKey())
->pluck('title', 'id')
->all());2
3
4
5
6
7
8
Catatan
- Setiap query tanpa scope adalah potensi kebocoran. Inilah biaya utama arsitektur ini dan alasan scoped resource tanpa tenant terikat memilih melempar exception alih-alih tetap berjalan.
Tenancy::key()mengembalikangetTenantKey(). Dengan model contoh di atas nilainya adalah primary key sehingga sesuai denganworkspace_id. JikagetTenantKey()mengembalikan slug, gunakanTenancy::require()->getKey()sebagai pembanding.- Unique constraint berlaku per database, bukan per tenant. Email yang harus unik di seluruh tenant biasanya tidak sesuai untuk pola ini; masukkan tenant ke dalam index, misalnya
unique(['workspace_id', 'email']). - Menghapus tenant berarti menghapus row pada shared table. Cascade adalah keputusan desain Anda, dan
cascadeOnDelete()pada tabel besar dapat menjadi transaksi yang panjang. - Test bawaan framework menggunakan arsitektur ini (
tests/Feature/Panel/TenancyTest.php) karena pada arsitektur connection-per-tenant tidak ada panel-level scope yang perlu diuji. - User tanpa workspace mendapatkan 403 pada setiap halaman panel. Tentukan tujuan alternatifnya — onboarding page pada central panel biasanya menjadi pilihan paling masuk akal.