Aplikasi SaaS multi-tenant adalah satu codebase yang melayani banyak pelanggan sekaligus: satu instalasi, banyak perusahaan yang datanya tidak boleh saling terlihat. Setiap pelanggan itu kita sebut tenant — Toko Bunga Melati dan Studio Foto Cendrawasih akan hidup berdampingan di database yang sama pada proyek ini. Sebelum satu baris kode PHP ditulis, kita harus memutuskan bagaimana kepemilikan data tenant tersebut dinyatakan di dalam database. Pada bagian ini kita memilih arsitektur penyimpanan, merancang empat tabel SQLite, lalu membuktikan lewat PHP bahwa setiap aturan — slug unik, role sah, satu keanggotaan per tenant — benar-benar ditegakkan database.
Tiga Arsitektur Multi-Tenant #
Ada tiga cara umum menyimpan data banyak tenant, dan pilihan ini menentukan hampir semua kode berikutnya:
| Arsitektur | Isolasi | Kompleksitas | Cocok untuk |
|---|---|---|---|
| Database per tenant | Paling kuat (fisik terpisah) | Tinggi: banyak koneksi, migrasi massal | Enterprise dengan sedikit tenant besar |
| Schema per tenant (satu server) | Kuat (logis terpisah) | Sedang: satu schema per tenant | PostgreSQL/MySQL menengah |
| Shared database, shared schema | Tergantung disiplin kode | Rendah: satu schema, satu koneksi | Jumlah tenant besar, tim kecil |
Proyek ini memilih baris ketiga: shared database, shared schema — satu database SQLite berisi satu set tabel untuk semua tenant, dan setiap baris data tenant membawa kolom tenant_id sebagai penanda kepemilikan. Pilihan ini paling realistis untuk SQLite (yang tidak punya konsep schema terpisah), paling murah dioperasikan pada jumlah tenant besar, dan lazim dipakai aplikasi SaaS tahap awal. Konsekuensinya jujur kita terima sejak awal: database hanya melabeli kepemilikan, sedangkan isolasi menjadi tanggung jawab lapisan kode — setiap query harus menyaring tenant_id, dan itu pekerjaan Bagian 2 sampai Bagian 6.
User Global, Keanggotaan Tersendiri #
Satu keputusan model paling menentukan: manusia dan tenant dipisah. Tabel users bersifat global tanpa tenant_id, karena satu orang boleh bekerja di banyak perusahaan — persis seperti satu akun Google bisa punya akun pribadi dan akun kantor. Hubungan “siapa anggota tenant mana dengan role apa” hidup di tabel ketiga bernama memberships, sebuah junction table antara users dan tenants. Dari desain ini mengalir tiga fakta yang dipakai seluruh proyek: login tidak bergantung tenant, daftar tenant seorang user dibaca dari memberships, dan role selalu diperiksa per pasangan user–tenant.
Tabel tenants dan users #
Dua tabel induk kita buat lebih dulu karena memberships merujuk keduanya. Berikut definisinya sebagaimana ditulis pada berkas schema.sql:
CREATE TABLE IF NOT EXISTS tenants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nama TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
dibuat_pada TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL
);Empat keputusan pada deklarasi tersebut:
slug TEXT NOT NULL UNIQUE: slug adalah pengenal tenant di URL dan API (misalnyastudio-foto-cendrawasih). BatasanUNIQUEmenjamin dua tenant tidak pernah memakai slug sama, sehingga slug aman dijadikan kunci pencarian pada Bagian 2.dibuat_pada TEXT NOT NULL: waktu pembuatan tenant disimpan eksplisit formatY-m-d H:i:s, bukanDEFAULT CURRENT_TIMESTAMP, karena seluruh data contoh proyek ini dibekukan agar output tiap bagian reproducible.email ... UNIQUE COLLATE NOCASE: keunikan email berlaku tanpa membedakan kapitalisasi, sehingga[email protected]dan[email protected]dianggap satu orang — perilaku yang sama dengan proyek Sistem Autentikasi .password_hash TEXT NOT NULL: kolom ini menyimpan hash bcrypt, bukan sandi mentah. Proyek ini tidak membahas hashing lagi — materinya lengkap di chapter Password Hashing .
Tabel memberships dan Primary Key Komposit #
Tabel inilah jantung model multi-tenant kita. Berikut definisinya:
CREATE TABLE IF NOT EXISTS memberships (
user_id INTEGER NOT NULL REFERENCES users(id),
tenant_id INTEGER NOT NULL REFERENCES tenants(id),
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
PRIMARY KEY (user_id, tenant_id)
);Ada tiga keputusan penting pada deklarasi tersebut:
PRIMARY KEY (user_id, tenant_id)adalah composite primary key: keunikan baris ditegakkan atas gabungan dua kolom, bukan per kolom. Satu user hanya boleh punya satu keanggotaan di satu tenant — baris(1, 1)tidak bisa muncul dua kali — tetapi user yang sama bebas menjadi anggota banyak tenant, dan satu tenant bebas punya banyak anggota. Perhatikan juga tidak ada kolomidsama sekali: pasangan user–tenant sudah unik, jadi kolom identitas tambahan hanya akan menggoda kode untuk mengabaikan pasangan alami itu.role ... CHECK (role IN ('owner', 'admin', 'member')): tiga tingkat akses ditegakkan di database. String di luar daftar ditolak dengan SQLSTATE23000sebelum sempat masuk; matriks siapa boleh melakukan apa atas role tersebut baru ditegakkanAccessControlpada Bagian 3.- Dua foreign key: keanggotaan harus merujuk user dan tenant yang benar-benar ada. Dengan
PRAGMA foreign_keys = ON, membership untuk user #99 langsung ditolak.
Tabel projects Milik Tenant #
Tabel pertama yang benar-benar dimiliki tenant menunjukkan pola yang akan diulangi terus:
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL REFERENCES tenants(id),
nama TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_projects_tenant_id ON projects(tenant_id);
CREATE INDEX IF NOT EXISTS idx_memberships_tenant_id ON memberships(tenant_id);Kolom tenant_id INTEGER NOT NULL REFERENCES tenants(id) wajib diisi dan harus merujuk tenant yang sah — proyek tanpa pemilik tidak boleh ada. Index idx_projects_tenant_id dibuat sejak awal karena hampir semua query masa depan berbentuk WHERE tenant_id = ?; tanpa index, setiap pembacaan daftar proyek memindai seluruh tabel lintas tenant. Index pada memberships.tenant_id melayani pertanyaan sebaliknya: siapa saja anggota satu tenant (dipakai listByTenant pada Bagian 3).
Menguji Struktur dari PHP #
Skema belum bisa dipercaya sebelum strukturnya terbukti. SQLite menyimpan daftar objeknya pada tabel sqlite_master, dan urutan kolom dalam primary key bisa dibaca lewat PRAGMA table_info. Skrip berikut memuat schema.sql ke database :memory: lalu memeriksa keduanya:
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/schema.sql'));
echo "Objek database hasil schema.sql:\n";
foreach ($pdo->query(
"SELECT type, name FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name"
) as $objek) {
echo " {$objek['type']}: {$objek['name']}\n";
}
echo "\nStruktur kolom memberships:\n";
foreach ($pdo->query('PRAGMA table_info(memberships)') as $kolom) {
echo " {$kolom['name']} bertipe {$kolom['type']},"
. " wajib diisi: {$kolom['notnull']},"
. " urutan pk: {$kolom['pk']}\n";
}Output yang Dihasilkan:
Objek database hasil schema.sql:
index: idx_memberships_tenant_id
index: idx_projects_tenant_id
table: memberships
table: projects
table: tenants
table: users
Struktur kolom memberships:
user_id bertipe INTEGER, wajib diisi: 1, urutan pk: 1
tenant_id bertipe INTEGER, wajib diisi: 1, urutan pk: 2
role bertipe TEXT, wajib diisi: 1, urutan pk: 0Empat tabel dan dua index tampil di sqlite_master, dan baris urutan pk: 1 pada user_id serta urutan pk: 2 pada tenant_id adalah bukti bahwa primary key komposit benar-benar terpasang di atas kedua kolom tersebut. Kolom role memiliki urutan pk: 0 karena tidak ikut menjadi bagian kunci. Mari kita pastikan data awal masuk dengan kepemilikan yang benar:
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/schema.sql'));
echo "Tenant:\n";
foreach ($pdo->query('SELECT id, nama, slug, dibuat_pada FROM tenants ORDER BY id') as $t) {
echo " #{$t['id']} {$t['nama']} (slug: {$t['slug']}, dibuat: {$t['dibuat_pada']})\n";
}
echo "\nKeanggotaan (user global, role per tenant):\n";
$sql = 'SELECT u.email, t.slug, m.role
FROM memberships m
JOIN users u ON u.id = m.user_id
JOIN tenants t ON t.id = m.tenant_id
ORDER BY m.user_id, m.tenant_id';
foreach ($pdo->query($sql) as $m) {
echo " {$m['email']} @ {$m['slug']} => {$m['role']}\n";
}
echo "\nProyek milik tenant (satu tabel, dua pemilik):\n";
$sql = 'SELECT t.slug, p.id, p.nama
FROM projects p
JOIN tenants t ON t.id = p.tenant_id
ORDER BY p.tenant_id, p.id';
foreach ($pdo->query($sql) as $p) {
echo " [{$p['slug']}] #{$p['id']} {$p['nama']}\n";
}Output yang Dihasilkan:
Tenant:
#1 Toko Bunga Melati (slug: toko-bunga-melati, dibuat: 2026-01-05 09:00:00)
#2 Studio Foto Cendrawasih (slug: studio-foto-cendrawasih, dibuat: 2026-01-06 10:30:00)
Keanggotaan (user global, role per tenant):
[email protected] @ toko-bunga-melati => owner
[email protected] @ studio-foto-cendrawasih => owner
[email protected] @ toko-bunga-melati => member
[email protected] @ studio-foto-cendrawasih => admin
Proyek milik tenant (satu tabel, dua pemilik):
[toko-bunga-melati] #1 Website Baru
[toko-bunga-melati] #2 Kampanye Valentine
[studio-foto-cendrawasih] #3 Fotografi Produk Sabun
[studio-foto-cendrawasih] #4 Branding Ulang 2026
[studio-foto-cendrawasih] #5 Katalog Musim HujanDua baris keanggotaan milik [email protected] adalah bukti desain user-global bekerja: Dewi adalah member di Toko Bunga Melati sekaligus admin di Studio Foto Cendrawasih, dua role berbeda pada dua tenant berbeda, dari satu akun yang sama. Sementara itu seluruh proyek kedua tenant duduk di satu tabel projects yang sama — yang membedakan pemiliknya hanyalah kolom tenant_id. [email protected] terdaftar sebagai user tetapi tidak punya baris membership; akun tanpa keanggotaan seperti ini akan kita pakai membuktikan penolakan akses pada Bagian 3.
Membuktikan Constraint Bekerja #
Aturan yang paling penting adalah yang paling sering dilanggar. Lima penyisipan berikut sengaja melanggar primary key komposit, CHECK role, keunikan slug, foreign key, dan keunikan email; semuanya ditangkap PDO dalam mode ERRMODE_EXCEPTION:
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/schema.sql'));
try {
$pdo->exec("INSERT INTO memberships (user_id, tenant_id, role) VALUES (1, 1, 'member')");
echo "Tersimpan.\n";
} catch (PDOException $e) {
echo "Keanggotaan ganda ditolak [{$e->getCode()}]\n";
echo " {$e->getMessage()}\n";
}
try {
$pdo->exec("INSERT INTO memberships (user_id, tenant_id, role) VALUES (4, 1, 'supervisor')");
echo "Tersimpan.\n";
} catch (PDOException $e) {
echo "Role 'supervisor' ditolak [{$e->getCode()}]\n";
}
try {
$pdo->exec("INSERT INTO tenants (nama, slug, dibuat_pada)
VALUES ('Toko Bunga Palsu', 'toko-bunga-melati', '2026-01-07 08:00:00')");
echo "Tersimpan.\n";
} catch (PDOException $e) {
echo "Slug duplikat ditolak [{$e->getCode()}]\n";
}
try {
$pdo->exec("INSERT INTO memberships (user_id, tenant_id, role) VALUES (99, 1, 'member')");
echo "Tersimpan.\n";
} catch (PDOException $e) {
echo "Membership ke user #99 ditolak [{$e->getCode()}]\n";
}
try {
$pdo->exec("INSERT INTO users (email, password_hash) VALUES ('[email protected]', 'x')");
echo "Tersimpan.\n";
} catch (PDOException $e) {
echo "Email duplikat beda kapital ditolak [{$e->getCode()}]\n";
}
$jumlahMembership = $pdo->query('SELECT COUNT(*) FROM memberships')->fetchColumn();
echo "Jumlah membership tetap: $jumlahMembership\n";Output yang Dihasilkan:
Keanggotaan ganda ditolak [23000]
SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: memberships.user_id, memberships.tenant_id
Role 'supervisor' ditolak [23000]
Slug duplikat ditolak [23000]
Membership ke user #99 ditolak [23000]
Email duplikat beda kapital ditolak [23000]
Jumlah membership tetap: 4Kelima pelanggaran ditolak dengan kode SQLSTATE 23000. Pesan kesalahan pertama adalah yang paling penting untuk dibaca pelan-pelan: UNIQUE constraint failed: memberships.user_id, memberships.tenant_id menyebut dua kolom sekaligus — database menegakkan keunikan pasangan (user_id, tenant_id), bukan tiap kolom sendiri-sendiri. Penolakan terakhir membuktikan COLLATE NOCASE bekerja: [email protected] dianggap duplikat dari [email protected] meskipun kapitalnya berbeda.
Cara Menjalankan Contoh #
Seluruh snippet pada bagian ini membutuhkan schema.sql yang tersedia pada bagian bawah halaman. Susun foldernya sebagai berikut:
aplikasi-saas-multi-tenant/
└── bagian-1-tenant-dan-domain-model/
├── schema.sql
└── uji-skema.phpSimpan salah satu snippet pengujian di atas sebagai uji-skema.php di samping schema.sql, lalu jalankan dari dalam folder tersebut:
php uji-skema.phpPHP membaca schema.sql relatif terhadap __DIR__, yaitu folder tempat uji-skema.php berada, sehingga perintah di atas bisa dijalankan dari folder mana pun asalkan kedua berkas tetap satu folder. Seluruh contoh memakai sqlite::memory:, jadi tidak ada berkas database yang tersisa setelah eksekusi. Bagian-bagian berikutnya menambah folder saudara seperti bagian-2-tenant-isolation/ dan merujuk schema.sql ini lewat path relatif ../bagian-1-tenant-dan-domain-model/schema.sql.
Apa yang Berubah #
Proyek dimulai dari folder kosong dan kini memiliki model data multi-tenant yang lengkap:
- Berkas
schema.sqlmenjadi satu-satunya sumber kebenaran struktur data: empat tabel (tenants,users,memberships,projects) dan dua index pada kolomtenant_id. - Keputusan shared-database shared-schema ditegakkan dari lapisan paling bawah: setiap tabel milik tenant membawa
tenant_id NOT NULLyang merujuk tenant sah. - Users bersifat global dan dipisahkan dari tenant lewat
membershipsber-PK komposit(user_id, tenant_id)— satu keanggotaan per tenant per user, roleowner/admin/memberditegakkanCHECK. - Dua tenant, empat user (termasuk Dewi yang jadi anggota dua tenant sekaligus), empat keanggotaan, dan lima proyek menyertai schema dengan waktu dibekukan, membuat setiap demo pada bagian berikutnya langsung berisi data yang reproducible.
Ringkasan #
Model data multi-tenant menegakkan aturan persis di tempat yang paling sulit dilanggar: primary key komposit menjaga satu keanggotaan per tenant, CHECK menjaga role tetap sah, dan foreign key menjaga setiap proyek memiliki pemilik. Namun label bukanlah isolasi — satu query lupa menyaring tenant_id, dan proyek Studio Foto Cendrawasih tampil di layar Toko Bunga Melati. Pada Bagian 2 kita membangun pertahanan pertama di lapisan kode: TenantContext yang membawa identitas tenant aktif ke setiap operasi, dan TenantRepository yang query-nya selalu ter-scoped.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
schema.sql
Unduh berkas-- schema.sql — Aplikasi SaaS Multi-Tenant
-- Basis data SQLite dengan pendekatan shared-database shared-schema:
-- SATU database untuk seluruh tenant; setiap tabel data tenant
-- membawa kolom tenant_id sebagai penanda kepemilikan.
--
-- KEPUTUSAN DESAIN PENTING:
-- 1. Tabel `users` GLOBAL (tanpa tenant_id): satu akun manusia bisa
-- menjadi anggota banyak tenant lewat tabel `memberships`.
-- 2. Tabel `memberships` adalah tabel junction user↔tenant dengan
-- PRIMARY KEY komposit (user_id, tenant_id): satu orang maksimal
-- satu keanggotaan per tenant, tetapi boleh di banyak tenant.
-- 3. Setiap tabel milik tenant (contoh: `projects`) WAJIB punya
-- kolom tenant_id — tidak ada pengecualian.
--
-- Catatan PRAGMA foreign_keys:
-- SQLite menonaktifkan pemeriksaan foreign key secara default pada
-- setiap koneksi baru. Pragma bersifat per koneksi sehingga tidak
-- efektif ditulis di berkas ini; kode PHP-lah yang menyalakannya
-- setiap kali membuka koneksi (lihat constructor repository).
CREATE TABLE IF NOT EXISTS tenants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nama TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
dibuat_pada TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL
);
-- Junction user↔tenant. PK komposit menolak keanggotaan ganda
-- pada tenant yang sama; kolom role membedakan tingkat akses.
CREATE TABLE IF NOT EXISTS memberships (
user_id INTEGER NOT NULL REFERENCES users(id),
tenant_id INTEGER NOT NULL REFERENCES tenants(id),
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
PRIMARY KEY (user_id, tenant_id)
);
-- Tabel milik tenant: selalu membawa tenant_id. Index pada
-- tenant_id melayani query ter-scoped yang dipakai seluruh bagian
-- proyek berikutnya (WHERE tenant_id = ?).
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL REFERENCES tenants(id),
nama TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_projects_tenant_id ON projects(tenant_id);
CREATE INDEX IF NOT EXISTS idx_memberships_tenant_id ON memberships(tenant_id);
-- ============================================================
-- Data awal (seed) — seluruh waktu dibekukan agar contoh pada
-- setiap bagian menghasilkan output yang sama persis.
-- Muat schema.sql ini hanya pada database yang masih kosong;
-- blok INSERT di bawah tidak memiliki penanda UNIQUE untuk
-- dideduplikasi (kecuali tabel users/tenants) sehingga eksekusi
-- kedua akan menggandakan data pada tabel memberships/projects.
--
-- Sandi demo (hanya untuk pembelajaran, sudah dibekukan sebagai
-- hash bcrypt cost 12 di bawah):
-- [email protected] -> sandi-budi-2026
-- [email protected] -> sandi-siti-2026
-- [email protected] -> sandi-dewi-2026
-- [email protected] -> sandi-rudi-2026 (terdaftar, belum
-- menjadi anggota tenant mana pun)
-- ============================================================
INSERT INTO tenants (id, nama, slug, dibuat_pada) VALUES
(1, 'Toko Bunga Melati', 'toko-bunga-melati', '2026-01-05 09:00:00'),
(2, 'Studio Foto Cendrawasih', 'studio-foto-cendrawasih', '2026-01-06 10:30:00');
INSERT INTO users (id, email, password_hash) VALUES
(1, '[email protected]', '$2y$12$7VsYexVvIwQBzbVTWe9GsOn1u5nVbYeK7XOSNIkZ/cgm3f5PazpCe'),
(2, '[email protected]', '$2y$12$p190v5EcESEoKGxFT7KkiewSJRxfcDH3L5PHb4DSg8IVR973iZ/V.'),
(3, '[email protected]', '$2y$12$l7pbIj5S9lQHQxE38ch8NerYEzgx3T99xOIqOOY3EjJUC3bjogpve'),
(4, '[email protected]', '$2y$12$daLYv/p710iSVhv.nkuJ7uHtlm5XoxLJiKkKVduHH0cpktCufvVS.');
-- Dewi (user 3) sengaja menjadi anggota DUA tenant sekaligus
-- dengan role berbeda — bukti bahwa user bersifat global.
INSERT INTO memberships (user_id, tenant_id, role) VALUES
(1, 1, 'owner'),
(2, 2, 'owner'),
(3, 1, 'member'),
(3, 2, 'admin');
INSERT INTO projects (id, tenant_id, nama) VALUES
(1, 1, 'Website Baru'),
(2, 1, 'Kampanye Valentine'),
(3, 2, 'Fotografi Produk Sabun'),
(4, 2, 'Branding Ulang 2026'),
(5, 2, 'Katalog Musim Hujan');