Multi-tenancy artinya satu aplikasi melayani banyak organisasi sekaligus, dan data tiap organisasi terpisah: yang satu gak boleh melihat data yang lain. Tiap organisasi itu disebut tenant, artinya penyewa. Bayangin gedung apartemen. Gedungnya satu, lift dan pipa airnya dipakai bersama, tapi tiap unit punya kunci sendiri, dan penghuni unit 3 gak bisa masuk ke unit 7.
🧪 Tebakan cepat: aplikasi kasir Kedai Senja sekarang juga dipakai Kopi Pagi. Admin Kedai Senja membuka widget "Pelanggan terbaru", yang kodenya
Customer::latest('id')->take(5)->get(), tanpa filter tenant. Yang muncul cuma pelanggan Kedai Senja, atau pelanggan Kopi Pagi juga? Tebak dulu, jawabannya ada di bagian "Satu query lupa scope".
Tabel yang kita pakai
Aplikasi kasir Kedai Senja laku, dan sekarang disewakan ke kedai lain. Kedai Senja jadi tenant 1, Kopi Pagi tenant 2. Cara paling sederhana: semua tenant memakai tabel yang sama, dan setiap baris diberi kolom tenant_id:
CREATE TABLE tenants (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
phone TEXT
);
CREATE INDEX customers_tenant_id_index ON customers (tenant_id);
INSERT INTO tenants VALUES (1, 'Kedai Senja'), (2, 'Kopi Pagi');
INSERT INTO customers VALUES
(1, 1, 'Budi', '0812-1111-2222'),
(2, 1, 'Siti', '0813-5555-6666'),
(3, 2, 'Dewi', '0821-3333-4444'),
(4, 1, 'Andi', '0819-7777-8888'),
(5, 2, 'Rina', '0822-6666-7777');
Setiap query wajib memfilter tenant-nya:
SELECT * FROM customers WHERE tenant_id = 1; -- Budi, Siti, Andi
Index di tenant_id wajib juga, karena kolom itu ikut di hampir setiap query.
Satu query lupa scope
Halaman Pelanggan ditulis dengan benar. Widget dashboard ditulis buru-buru:
// halaman Pelanggan: benar
Customer::where('tenant_id', $tenantId)->get();
// widget "Pelanggan terbaru": lupa filter tenant
Customer::latest('id')->take(5)->get();
SELECT * FROM customers ORDER BY id DESC LIMIT 5;
| id | tenant_id | name | phone |
|---|---|---|---|
| 5 | 2 | Rina | 0822-6666-7777 |
| 4 | 1 | Andi | 0819-7777-8888 |
| 3 | 2 | Dewi | 0821-3333-4444 |
| 2 | 1 | Siti | 0813-5555-6666 |
| 1 | 1 | Budi | 0812-1111-2222 |
Rina dan Dewi, pelanggan Kopi Pagi, muncul di dashboard Kedai Senja, lengkap dengan nomor HP-nya. Itu jawaban tebakannya. Gak ada error sama sekali, query-nya "berhasil". Ini bug paling berbahaya di aplikasi multi-tenant, karena hasilnya kebocoran data.
Global scope: filter yang gak bisa lupa
Daripada mengandalkan semua developer ingat menulis where('tenant_id', ...), pasang filternya sekali di model lewat global scope, filter yang otomatis ditambahkan Eloquent ke setiap query model itu:
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $query) {
$query->where('tenant_id', auth()->user()->tenant_id);
});
static::creating(function (Model $model) {
$model->tenant_id ??= auth()->user()->tenant_id;
});
}
}
class Customer extends Model
{
use BelongsToTenant;
}
- Laravel otomatis memanggil method
boot+ nama trait (bootBelongsToTenant) saat model dipakai pertama kali. addGlobalScope('tenant', ...)menambahkanWHERE tenant_id = ?ke setiap queryCustomer. Widget tadi sekarang mengirimSELECT * FROM customers WHERE tenant_id = 1 ORDER BY id DESC LIMIT 5.creatingmengisitenant_idpelanggan baru secara otomatis.
Lubangnya sama seperti soft delete: global scope cuma milik Eloquent. DB::table('customers'), SQL mentah, dan withoutGlobalScope('tenant') melewatinya.
Tiga strategi multi-tenancy
Kolom tenant_id di tabel bersama baru satu dari tiga cara. Bedanya ada di seberapa terpisah data antar tenant:
- Shared tables (satu database, tabel bersama, kolom
tenant_id): seperti satu lemari besar, tiap kotak diberi label nama pemiliknya. Paling sederhana dan paling murah. Satu migration untuk semua tenant, tambah tenant cukup satu baris ditenants. Risikonya: satu query lupa filter, data bocor. - Separate schema (schema per tenant): satu database, tapi tiap tenant punya schema sendiri. Di PostgreSQL, schema adalah folder tabel di dalam satu database:
kedai_senja.customersdankopi_pagi.customersadalah dua tabel berbeda. Per request, aplikasi menjalankanSET search_path TO kopi_pagi;, dan query-nya gak butuhtenant_idlagi. Isolasi lebih baik, tapi setiap migration dijalankan sekali per schema. - Separate database (database per tenant): tiap tenant punya database sendiri, dan per request aplikasi memilih koneksi ke database tenant itu. Isolasinya paling kuat: query yang lupa filter pun cuma bisa melihat data tenant itu, karena data tenant lain gak ada di database tersebut. Backup dan restore satu tenant tinggal database-nya. Harganya paling mahal: ratusan database untuk dirawat, migration per database, dan koneksi per database.
| Shared tables | Separate schema | Separate database | |
|---|---|---|---|
| Isolasi | Rendah | Sedang | Tinggi |
| Tambah tenant | 1 baris di tenants |
CREATE SCHEMA + migration |
CREATE DATABASE + migration |
| Migration | Sekali untuk semua | Sekali per schema | Sekali per database |
| Backup satu tenant | Susah, harus difilter per tabel | Per schema | Tinggal database-nya |
| Biaya | Paling murah | Sedang | Paling mahal |
Aturan di latihan mengikuti baris "Isolasi" itu: kebutuhan isolasi "rendah" jadi "shared tables", "sedang" jadi "separate schema", "tinggi" jadi "separate database", dan yang lain "shared tables". Contoh isolasi tinggi: klinik atau bank yang kontraknya mewajibkan datanya disimpan terpisah.
Di Lab, database per tenant ditiru dengan file database SQLite per tenant. Request Kopi Pagi cuma membuka file kopi_pagi.db (lewat ATTACH), jadi SELECT * FROM customers tanpa filter pun gak bisa menyentuh pelanggan Kedai Senja.
📌 Row Level Security di PostgreSQL
PostgreSQL bisa memaksa filter tenant di level database, jadi query yang lupa WHERE pun tetap aman:
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON customers
USING (tenant_id = current_setting('app.tenant_id')::int);
Per request, aplikasi menjalankan SET app.tenant_id = '1';, dan database sendiri yang menyaring barisnya. Perhatikan: pemilik tabel melewati policy ini, kecuali ditambah ALTER TABLE customers FORCE ROW LEVEL SECURITY;. Package Laravel seperti stancl/tenancy dan spatie/laravel-multitenancy mengurus pemilihan tenant per request untuk ketiga strategi.
⚠️ Jebakan yang sering ketemu
- Satu query lupa
WHERE tenant_id. Gak ada error, datanya bocor. Pakai global scope, jangan mengandalkan ingatan. DB::table(), SQL mentah, atauwithoutGlobalScope(). Semuanya melewati global scope. Tulis filternya sendiri di query seperti itu.- Job antrian dan perintah artisan. Gak ada pengguna yang login, jadi
auth()->user()bernilainull. Tentukan tenant-nya secara eksplisit di job. - Lupa index di
tenant_id. Setiap query membaca baris semua tenant dulu sebelum menyaring. - Teks di latihan. Kembalikan persis
"shared tables","separate schema"atau"separate database". ParameterjumlahTenantgak memengaruhi hasilnya.
TL;DR: Multi-tenancy: satu aplikasi melayani banyak organisasi (tenant) dengan data terpisah. Cara paling sederhana adalah shared tables: satu database, kolom tenant_id di setiap tabel, dan setiap query wajib WHERE tenant_id = ?. Satu query yang lupa filter membocorkan data tenant lain tanpa error, jadi pasang global scope di model. Separate schema memberi tiap tenant schema sendiri (isolasi sedang). Separate database memberi tiap tenant database sendiri: isolasi paling kuat dan backup per tenant gampang, tapi paling mahal dirawat.