Aplikasi SaaS multi-tenant melayani banyak pelanggan (tenant) dari satu kode aplikasi, dengan data setiap tenant terpisah. Di Laravel, pendekatan paling umum untuk memulai adalah satu database dengan kolom tenant_id di setiap tabel dan global scope yang otomatis memfilter query. Pendekatan database terpisah per tenant memberi isolasi lebih kuat, tapi biaya operasionalnya lebih tinggi.
Tiga Model Database Multi-Tenant
| Model | Isolasi | Biaya operasional | Cocok untuk |
|---|---|---|---|
Satu database, kolom tenant_id | Logis (lewat kode) | Rendah | Ratusan sampai ribuan tenant kecil |
| Satu database, skema per tenant | Sedang | Sedang | PostgreSQL, tenant menengah |
| Database terpisah per tenant | Kuat | Tinggi (migrasi dan backup per database) | Klien korporat dengan syarat kepatuhan ketat |
Paket seperti stancl/tenancy mendukung beberapa model ini. Contoh di bawah memakai model pertama, ditulis sendiri agar jelas cara kerjanya.
Langkah 1: Tabel dan Model Tenant
Schema::create('tenants', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('subdomain')->unique();
$table->string('plan')->default('basic');
$table->timestamps();
});
Schema::create('invoices', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('number');
$table->unsignedBigInteger('total');
$table->timestamps();
$table->unique(['tenant_id', 'number']); // nomor unik per tenant, bukan global
$table->index(['tenant_id', 'created_at']);
});
Perhatikan index yang selalu diawali tenant_id. Hampir semua query akan memfilter berdasarkan tenant, jadi index harus mengikuti pola itu. Prinsipnya dijelaskan di MySQL performance tuning.
Langkah 2: Mengenali Tenant dari Subdomain
class IdentifyTenant
{
public function handle(Request $request, Closure $next): Response
{
$subdomain = explode('.', $request->getHost())[0];
$tenant = Tenant::where('subdomain', $subdomain)->firstOrFail();
app()->instance(Tenant::class, $tenant);
Context::add('tenant_id', $tenant->id); // ikut di log dan job
return $next($request);
}
}
Langkah 3: Global Scope Otomatis
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $query) {
if (app()->bound(Tenant::class)) {
$query->where($query->getModel()->getTable().'.tenant_id', app(Tenant::class)->id);
}
});
static::creating(function (Model $model) {
if (app()->bound(Tenant::class) && ! $model->tenant_id) {
$model->tenant_id = app(Tenant::class)->id;
}
});
}
}
class Invoice extends Model
{
use BelongsToTenant;
}
Sekarang Invoice::all() hanya mengembalikan invoice tenant aktif, dan invoice baru otomatis mendapat tenant_id.
Langkah 4: Bagian yang Sering Bocor
- Validasi unik dan exists. Aturan
Rule::unique('invoices', 'number')memeriksa seluruh tabel. Tambahkan->where('tenant_id', $tenant->id). - Query mentah dengan
DB::table()tidak memakai global scope. Tambahkan filter tenant secara manual. - Queue job berjalan di proses lain tanpa middleware. Simpan
tenant_iddi job dan set ulang tenant saat job dijalankan. - Cache: awali kunci dengan tenant, misalnya
"tenant:{$id}:dashboard", agar data tidak tertukar. Pola cache dibahas di Redis cache layer. - File: simpan di folder per tenant, misalnya
tenants/{id}/invoices/. - Route model binding tetap aman selama model memakai global scope, tapi pastikan tidak ada
withoutGlobalScopes()yang tertinggal.
Langkah 5: Test Isolasi Data
it('tidak bisa melihat invoice tenant lain', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$invoiceB = Invoice::factory()->for($tenantB)->create();
$this->actingAs(User::factory()->for($tenantA)->create())
->get("http://{$tenantA->subdomain}.aplikasi.test/invoices/{$invoiceB->id}")
->assertNotFound();
});
Tulis test semacam ini untuk setiap sumber daya penting. Kebocoran data antar tenant adalah insiden paling serius di aplikasi SaaS.
Paket dan Billing
- Simpan batasan paket (jumlah pengguna, kuota) di satu tempat dan cek lewat policy atau middleware.
- Untuk pembayaran berulang, Laravel Cashier mendukung Stripe dan Paddle. Untuk pasar Indonesia, integrasikan gateway lokal yang mendukung QRIS dan virtual account.
- Catat pemakaian per tenant agar mudah menganalisis paket mana yang menguntungkan.
Kesalahan yang Sering Terjadi
- Database per tenant sejak hari pertama untuk produk yang belum punya pelanggan, sehingga migrasi dan backup jadi rumit tanpa manfaat.
- Admin internal memakai query tanpa scope lalu kodenya tersalin ke fitur pengguna.
- Lupa tenant di job dan command terjadwal, sehingga laporan berisi data campuran.
Langkah Berikutnya
Kalau kamu sedang merancang produk SaaS kecil dari ide sampai peluncuran, baca membangun micro-SaaS dengan bantuan AI. Untuk pola kode yang menjaga aplikasi tetap rapi saat fitur bertambah, lihat design pattern untuk Laravel.