โ† Semua pembelajaran / Laravel Nol โ†’ Enterprise
Fase 10 ยท Enterprise & Capstone

Multi-tenancy

Multi-tenancy adalah keputusan arsitektur yang sangat mahal untuk diubah belakangan. Yang menentukan bukan kerapian kode, melainkan seberapa kuat isolasi data yang benar-benar kamu butuhkan.

Intisari

  • Tiga pola: satu database dengan kolom tenant, satu database per tenant, dan skema terpisah.
  • Pola kolom paling murah dan paling umum โ€” tapi seluruh isolasinya bergantung pada kode yang benar.
  • Global scope adalah pertahanan utamanya, dan ia harus punya jalan keluar yang jelas.
  • Yang paling sering bocor: job antrean, perintah Artisan, dan kunci cache.
  • Uji kebocoran sebagai bagian dari suite tes โ€” bukan sebagai review manual.

Tiga pola

Kolom tenantDatabase per tenantSkema per tenant
IsolasiLogis (bergantung kode)FisikKuat
Biaya per tenantNyaris nolTinggiSedang
MigrasiSekaliPer tenantPer tenant
Query lintas tenantMudahSulitSulit
Backup per tenantSulitMudahSedang
Skala ribuan tenantBaikBurukSedang
Cocok untukSaaS umumEnterprise, kepatuhan ketatJalan tengah

Kalau ragu, mulai dari kolom tenant. Ia mendukung ribuan pelanggan dengan satu set migrasi dan satu klaster database. Pindah ke database terpisah nanti memang berat, tapi biasanya hanya perlu dilakukan untuk segelintir pelanggan besar yang menuntutnya โ€” dan pola hibrida seperti itu lazim.

Pola kolom tenant

// Trait yang dipakai semua model milik tenant
trait MilikTenant
{
    protected static function bootMilikTenant(): void
    {
        static::addGlobalScope('tenant', function (Builder $q) {
            if ($id = Tenant::aktif()?->id) {
                $q->where($q->getModel()->getTable().'.tenant_id', $id);
            }
        });

        static::creating(function (Model $model) {
            $model->tenant_id ??= Tenant::aktif()?->id
                ?? throw new RuntimeException('Tidak ada tenant aktif.');
        });
    }
}

Perhatikan throw di baris terakhir. Membuat baris tanpa tenant aktif harus menjadi error, bukan menghasilkan baris dengan tenant_id kosong yang kemudian terlihat oleh semua orang. Gagal keras di sini jauh lebih baik daripada kebocoran diam-diam.

// Konteks tenant โ€” scoped, BUKAN singleton (lihat Fase 8)
$this->app->scoped(KonteksTenant::class);

// Middleware yang menentukannya
class TentukanTenant
{
    public function handle(Request $request, Closure $next): Response
    {
        $tenant = Tenant::where('domain', $request->getHost())->firstOrFail();

        app(KonteksTenant::class)->set($tenant);

        return $next($request);
    }
}

Tiga tempat kebocoran paling sering terjadi

TempatKenapa bocorPerbaikan
Job antreanWorker tidak punya permintaan HTTP, jadi tidak ada tenant aktifSimpan tenant_id di job, pulihkan di awal handle()
Kunci cacheKunci tanpa ID tenant dipakai bersama semua tenantSertakan tenant di setiap kunci
Perintah ArtisanTidak ada tenant; global scope tidak aktifIterasi tenant secara eksplisit
Query builder mentahDB::table() melewati global scopeHindari, atau filter manual
Berkas di S3Path tanpa tenantPrefiks path dengan ID tenant
// Job yang membawa tenant-nya sendiri
class BuatLaporanBulanan implements ShouldQueue
{
    public function __construct(
        public readonly int $tenantId,
        public readonly string $bulan,
    ) {}

    public function handle(): void
    {
        Tenant::jalankanSebagai($this->tenantId, function () {
            // Di dalam sini, global scope kembali aktif
            $artikel = Artikel::whereMonth('terbit_pada', $this->bulan)->get();
            // ...
        });
    }
}
// Kunci cache SELALU mengandung tenant
Cache::remember("t{$tenantId}:dasbor:harian", 300, fn () => $this->hitung());

// Berkas juga
Storage::disk('s3')->put("tenant/{$tenantId}/laporan/{$nama}.pdf", $isi);

Kunci cache adalah kebocoran yang paling sulit dilihat. Ia tidak muncul di tes yang menjalankan satu tenant, tidak muncul di review kode yang tidak memikirkan cache, dan gejalanya berupa "kadang datanya aneh" yang sangat sulit direproduksi. Buat aturan tim yang absolut: setiap kunci cache diawali ID tenant, tanpa kecuali โ€” dan tegakkan lewat wrapper, bukan lewat ingatan.

Menguji isolasi

it('tidak membocorkan artikel antar tenant', function () {
    $a = Tenant::factory()->create();
    $b = Tenant::factory()->create();

    $artikelA = Tenant::jalankanSebagai($a->id, fn () => Artikel::factory()->create());
    $artikelB = Tenant::jalankanSebagai($b->id, fn () => Artikel::factory()->create());

    Tenant::jalankanSebagai($b->id, function () use ($artikelA, $artikelB) {
        expect(Artikel::count())->toBe(1);
        expect(Artikel::find($artikelA->id))->toBeNull();      // tidak terlihat
        expect(Artikel::find($artikelB->id))->not->toBeNull();
    });
});

it('menolak akses lintas tenant lewat URL', function () {
    $artikelLain = Tenant::jalankanSebagai($b->id, fn () => Artikel::factory()->create());

    actingAsTenant($a)
        ->get("/artikel/{$artikelLain->id}")
        ->assertNotFound();                                    // 404, bukan 403
});

Tes seperti ini wajib ada untuk setiap model milik tenant. Bukan satu tes untuk seluruh aplikasi โ€” satu untuk setiap model. Model baru yang lupa memakai trait MilikTenant adalah kebocoran yang menunggu terjadi, dan satu-satunya cara menangkapnya sebelum produksi adalah tes yang memang mencarinya. Sebuah arch() yang mewajibkan setiap model di folder tertentu memakai trait itu juga layak ditambahkan.

Yang perlu diputuskan di awal

  1. Cara mengenali tenant: subdomain, domain sendiri, atau path. Domain sendiri butuh sertifikat per pelanggan.
  2. Pengguna lintas tenant: bolehkah satu orang masuk ke dua tenant? Ini mengubah bentuk tabel.
  3. Data bersama: tabel apa yang memang global โ€” negara, mata uang, paket langganan.
  4. Kuota per tenant: batas penyimpanan, jumlah pengguna, dan laju permintaan.
  5. Menghapus tenant: apa yang benar-benar dihapus, dan berapa lama disimpan.

Latihan: tambahkan tenant_id dan trait MilikTenant pada satu model, lalu tulis kedua tes isolasi di atas. Setelah hijau, buat satu job yang lupa membawa tenant dan buktikan ia mengambil data yang salah โ€” lalu perbaiki.

Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.