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

ModelIsolasiBiaya operasionalCocok untuk
Satu database, kolom tenant_idLogis (lewat kode)RendahRatusan sampai ribuan tenant kecil
Satu database, skema per tenantSedangSedangPostgreSQL, tenant menengah
Database terpisah per tenantKuatTinggi (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_id di 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.