Enam bagian terakhir membangun satu aplikasi utuh di atas satu database bersama: domain tenant dengan schema shared-database, isolasi lewat TenantContext dan query ter-scope, matriks izin delapan aksi, siklus langganan empat status, audit trail append-only, job queue dengan retry, dan lima belas test yang menjaga semuanya. Satu pertanyaan terakhir belum terjawab: apakah semua ini akan berjalan identik di server lain — dan bila sesuatu berubah di sana, bagaimana kita mengetahuinya sebelum pelanggan menelepon? Bagian penutup ini mengurus kemasan, urutan migrasi saat start, pantauan, dan pemulihan.

Taruhannya memang naik dibanding aplikasi biasa. Pada aplikasi single-tenant, deployment yang gagal mengganggu satu kumpulan pengguna; pada aplikasi multi-tenant dengan database bersama, satu migrasi yang salah, satu health check yang bolong, atau satu backup yang ternyata tak bisa dipulihkan berdampak ke seluruh tenant sekaligus — pada proyek ini dua tenant kita, pada SaaS sungguhan bisa ribuan. Database bersama yang membuat pengembangan hemat (satu schema, satu koneksi, satu migrasi) sekaligus memusatkan risiko: blast radius kesalahan apa pun selalu “semua pelanggan”, bukan “satu pelanggan”. Karena itu bagian ini memperlakukan deployment bukan sebagai formalitas terakhir, melainkan sebagai lapisan keamanan tambahan.

Rencananya berurutan: mengemas seluruh proyek ke satu image Docker; menjalankan struktur dengan urutan yang tidak boleh ditukar setiap container start; memantau hal-hal yang hanya dimiliki SaaS (kuota per tenant, pertumbuhan audit, kedalaman antrean); health check yang menguji struktur dan bukan sekadar koneksi; lalu cara berpikir backup ketika “pulihkan data tenant A saja” bukan permintaan sederhana. Ditutup dengan checklist kesiapan produksi dan rekap tujuh bagian perjalanan.

Dockerfile: Mengemas Seluruh Proyek #

Docker menyelesaikan masalah parity: image membawa runtime dan kode sebagai satu kesatuan yang berjalan identik di komputer mana pun, dengan isolasi antar aplikasi pada satu host — pembahasan lengkapnya ada di chapter Docker dan PHP . Image kita bertumpu pada php:8.4-cli-alpine: varian CLI karena seluruh kode proyek berjalan di command line, basis Alpine karena ukurannya kecil, dan versi 8.4 terkunci agar perilaku build konsisten. Ekstensi pdo_sqlite sudah terbundel dalam image resmi PHP, jadi tidak ada docker-php-ext-install — pembaca yang ingin membuktikannya bisa menjalankan docker run --rm saas:kursus php -m. Berikut isi lengkap Dockerfile pada bundle bagian ini:

dockerfile
# Dockerfile - image aplikasi SaaS multi-tenant (Bagian 7).
#
# Build dari folder aplikasi-saas-multi-tenant/ (konteks build),
# bukan dari dalam folder bagian ini:
#   docker build -f bagian-7-deployment-dan-monitoring/Dockerfile -t saas:kursus .

FROM php:8.4-cli-alpine

# Konfigurasi lewat environment variable: satu-satunya hal yang
# boleh berbeda antar lingkungan tanpa membangun ulang image.
ENV DB_PATH=/data/saas.sqlite

WORKDIR /app

# Schema dasar (Bagian 1), dua migrasi (Bagian 4-5), dan seluruh
# class (Bagian 2-5) disalin merata ke /app: tiga berkas SQL di
# root, kedelapan class di src/.
COPY bagian-1-tenant-dan-domain-model/schema.sql ./schema.sql
COPY bagian-4-billing-dan-subscriptions/migrasi-billing.sql ./migrasi-billing.sql
COPY bagian-5-audit-dan-background-jobs/migrasi-audit-jobs.sql ./migrasi-audit-jobs.sql
COPY bagian-2-tenant-isolation/src/ ./src/
COPY bagian-3-membership-dan-permissions/src/ ./src/
COPY bagian-4-billing-dan-subscriptions/src/ ./src/
COPY bagian-5-audit-dan-background-jobs/src/ ./src/

# Direktori database milik user non-root; volume yang di-mount ke
# /data akan menimpanya saat runtime.
RUN mkdir -p /data && chown www-data:www-data /data

# Proses berjalan sebagai user non-root: bila proses di dalam
# container berhasil dieksploitasi, penyerang tidak langsung
# mendapat akses root - apalagi database ini berisi data SEMUA
# tenant sekaligus, satu celah berarti banyak pelanggan.
USER www-data

# Health check: buka database di DB_PATH lalu buktikan kedelapan
# tabel utuh terbaca - schema dasar dan KEDUA migrasi sama-sama
# harus sudah diterapkan. Pengecualian ditangkap agar kegagalan
# dilaporkan sebagai exit 1 (TIDAK SEHAT), bukan crash 255 -
# health check yang crash adalah laporan yang tidak bisa dibaca.
# Hanya bermakna bila CMD diganti entry point yang berjalan lama
# (worker antrean, php-fpm di belakang Nginx) - pada container
# sekali-jalan ia tidak sempat dievaluasi, tetapi resepnya siap.
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s \
    CMD php -r 'try { $pdo = new PDO("sqlite:" . getenv("DB_PATH")); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); foreach (["tenants", "users", "memberships", "projects", "plans", "subscriptions", "audit_log", "job_queue"] as $t) { $pdo->query("SELECT 1 FROM " . $t . " LIMIT 1"); } exit(0); } catch (Throwable $e) { exit(1); }'

# Smoke test start: muat kedelapan class (parse check - berkas
# rusak langsung terlihat), terapkan struktur berurutan - schema
# dasar HANYA bila tabel tenants belum ada (seed-nya tidak
# idempoten; menjalankannya dua kali menggandakan data), lalu dua
# migrasi yang aman diulang - dan buktikan isolasi dari sisi
# data: proyek dihitung per tenant lewat query ter-scope
# tenant_id. ERRMODE_EXCEPTION membuat migrasi yang gagal
# menghentikan start dengan exit 255, bukan melanjutkan dalam
# keadaan setengah terpasang.
CMD ["php", "-r", "foreach (glob('src/*.php') as $f) { require $f; } $pdo = new PDO('sqlite:' . getenv('DB_PATH')); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $siap = (int) $pdo->query(\"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='tenants'\")->fetchColumn(); if ($siap === 0) { $pdo->exec((string) file_get_contents('schema.sql')); } foreach (['migrasi-billing.sql', 'migrasi-audit-jobs.sql'] as $m) { $pdo->exec((string) file_get_contents($m)); } foreach ($pdo->query('SELECT id FROM tenants ORDER BY id')->fetchAll(PDO::FETCH_COLUMN) as $id) { $c = $pdo->prepare('SELECT COUNT(*) FROM projects WHERE tenant_id = ?'); $c->execute([$id]); echo 'tenant #' . $id . ': ' . $c->fetchColumn() . ' proyek' . PHP_EOL; } echo 'saas siap: ' . getenv('DB_PATH') . PHP_EOL;"]

Mari kita bedah baris per baris:

  1. FROM php:8.4-cli-alpine memulai dari image resmi PHP 8.4 varian CLI basis Alpine — runtime yang sama dengan yang dipakai sepanjang kursus, dengan pdo_sqlite terbundel.
  2. ENV DB_PATH=/data/saas.sqlite menetapkan konfigurasi default pada level image. Ini satu-satunya tombol lingkungan yang dibutuhkan aplikasi; docker run -e DB_PATH=... bisa menimpanya tanpa membangun ulang — pola yang dibahas pada chapter Environment Variables .
  3. WORKDIR /app menetapkan direktori kerja untuk COPY dan CMD berikutnya.
  4. Tujuh baris COPY menyalin tiga berkas SQL ke root /app dan kedelapan class dari Bagian 2-5 secara merata ke /app/src/. Konteks build adalah folder aplikasi-saas-multi-tenant/ (bukan folder bagian ini), sehingga semua bagian bisa dirujuk; di dalam image, struktur per bagian hilang — yang tinggal hanya aplikasi utuh.
  5. RUN mkdir -p /data && chown ... menyiapkan rumah database milik user non-root; volume yang di-mount ke /data akan menimpanya saat runtime, sehingga data bertahan melewati hidup-mati container.
  6. USER www-data menjalankan proses sebagai user non-root. Argumennya kali ini lebih tajam daripada aplikasi biasa: database ini berisi data semua tenant sekaligus, jadi proses yang berhasil dieksploitasi tidak hanya membocorkan satu pelanggan — celahnya berlipat jumlah tenant.
  7. HEALTHCHECK memberi Docker cara memeriksa container lewat kode exit: buka database di DB_PATH, lalu baca satu query murah dari masing-masing kedelapan tabel. Exit 0 berarti struktur lengkap dan terbaca; exit 1 berarti tidak sehat. Detail rancangannya kita bahas pada bagian health check di bawah.
  8. CMD dalam bentuk exec form menjalankan smoke test start: glob('src/*.php') memuat kedelapan class (sekaligus parse check — berkas rusak terlihat saat start), struktur diterapkan dengan urutan yang benar, lalu container mencetak jumlah proyek per tenant dan berhenti.

Dua keputusan pada CMD layak dijelaskan terbuka. Pertama, ERRMODE_EXCEPTION sengaja dipasang: migrasi yang gagal membuat start berhenti dengan exit 255 alih-alih melanjutkan dalam keadaan setengah terpasang — container yang gagal cepat lebih jujur daripada container yang hidup tapi kosong. Kedua, proyek kursus ini membangun library class beserta test-nya, bukan aplikasi interaktif — tidak ada CLI console atau router HTTP untuk dijadikan proses utama. Maka CMD yang jujur adalah verifikasi siap: container membuktikan seluruh class termuat, struktur terpasang berurutan, dan data per tenant terbaca, lalu selesai. Di produksi, titik ini diganti entry point nyata: worker yang mengonsumsi antrean JobQueue, atau image turunan php:8.4-fpm-alpine di belakang Nginx.

Urutan Migrasi yang Tidak Bisa Ditukar #

Tiga berkas SQL kini hidup berdampingan: schema.sql Bagian 1, migrasi-billing.sql Bagian 4, dan migrasi-audit-jobs.sql Bagian 5. Urutan pemuatannya adalah kontrak: schema dasar dulu, lalu kedua migrasi, karena foreign key subscriptions dan audit_log/job_queue menunjuk tabel tenants dan users yang baru ada setelah schema dasar. Menariknya, SQLite menerima urutan yang salah dengan diam-diam — CREATE TABLE dengan foreign key ke tabel yang belum ada tidak error saat dimuat, dan kegagalannya baru muncul saat operasi pertama yang menyentuh tabel tersebut. Kegagalan yang ditunda seperti ini lebih berbahaya daripada kegagalan keras: migrasi “berhasil”, deployment hijau, dan error baru menyala bersama request pengguna pertama. Demo berikut membuktikan keempat sifat itu sekaligus:

php
<?php

declare(strict_types=1);

require __DIR__ . '/../bagian-4-billing-dan-subscriptions/src/PlanRepository.php';
require __DIR__ . '/../bagian-4-billing-dan-subscriptions/src/SubscriptionService.php';

/**
 * Terapkan satu berkas SQL pada koneksi PDO.
 */
function terapkanSql(PDO $pdo, string $berkas): void
{
    $pdo->exec((string) file_get_contents($berkas));
}

function buatKoneksi(): PDO
{
    $pdo = new PDO('sqlite::memory:');
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    $pdo->exec('PRAGMA foreign_keys = ON');

    return $pdo;
}

$skema = __DIR__ . '/../bagian-1-tenant-dan-domain-model/schema.sql';
$billing = __DIR__ . '/../bagian-4-billing-dan-subscriptions/migrasi-billing.sql';
$auditJobs = __DIR__ . '/../bagian-5-audit-dan-background-jobs/migrasi-audit-jobs.sql';

echo "== Urutan benar: schema -> billing -> audit+jobs ==\n";
$pdo = buatKoneksi();
terapkanSql($pdo, $skema);
terapkanSql($pdo, $billing);
terapkanSql($pdo, $auditJobs);
$tabel = (int) $pdo->query(
    "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
)->fetchColumn();
$trigger = (int) $pdo->query(
    "SELECT COUNT(*) FROM sqlite_master WHERE type='trigger'"
)->fetchColumn();
printf("  %d tabel dan %d trigger terpasang\n", $tabel, $trigger);

echo "\n== Urutan terbalik: billing tanpa schema dasar ==\n";
$pdoSalah = buatKoneksi();
terapkanSql($pdoSalah, $billing);
echo "  migrasi dimuat tanpa error\n";
try {
    $svc = new SubscriptionService($pdoSalah, new PlanRepository($pdoSalah));
    $svc->subscribe(1, 'pro', new DateTimeImmutable('2026-02-01 09:00:00', new DateTimeZone('Asia/Jakarta')));
    echo "  (berhasil - tidak seharusnya)\n";
} catch (PDOException $e) {
    printf("  operasi billing pertama: %s\n", $e->getMessage());
}

echo "\n== Dua migrasi diulang pada database terisi ==\n";
terapkanSql($pdo, $billing);
terapkanSql($pdo, $auditJobs);
$tenant = (int) $pdo->query('SELECT COUNT(*) FROM tenants')->fetchColumn();
$plan = (int) $pdo->query('SELECT COUNT(*) FROM plans')->fetchColumn();
printf("  tetap %d tenant, %d plan - idempoten\n", $tenant, $plan);

echo "\n== Schema dasar diulang: seed TIDAK idempoten ==\n";
try {
    terapkanSql($pdo, $skema);
    echo "  (berhasil - tidak seharusnya)\n";
} catch (PDOException $e) {
    printf("  ditolak: %s\n", $e->getMessage());
}

Output yang Dihasilkan:

text
== Urutan benar: schema -> billing -> audit+jobs ==
  8 tabel dan 2 trigger terpasang

== Urutan terbalik: billing tanpa schema dasar ==
  migrasi dimuat tanpa error
  operasi billing pertama: SQLSTATE[HY000]: General error: 1 no such table: main.tenants

== Dua migrasi diulang pada database terisi ==
  tetap 2 tenant, 2 plan - idempoten

== Schema dasar diulang: seed TIDAK idempoten ==
  ditolak: SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: tenants.id

Empat blok output itu adalah empat pelajaran. Urutan benar memasang 8 tabel (4 dari schema dasar, 2 dari billing, 2 dari audit+jobs) dan 2 trigger append-only. Urutan terbalik memperlihatkan jebakannya: migrasi billing dimuat tanpa error pada database tanpa schema dasar, lalu subscribe() pertama — operasi billing paling awal yang menyentuh subscriptions — melempar no such table: main.tenants dari pemeriksaan foreign key. Dua migrasi terbukti idempoten: CREATE TABLE IF NOT EXISTS, INSERT OR IGNORE, dan CREATE TRIGGER IF NOT EXISTS membuatnya aman dijalankan pada setiap start. Sedangkan schema dasar TIDAK idempoten: seed-nya menyisipkan baris dengan id eksplisit, sehingga eksekusi kedua ditolak UNIQUE constraint failed: tenants.id — inilah alasan CMD pada Dockerfile memasang gerbang $siap === 0 sebelum menjalankan schema.sql.

Ada satu tingkat kedewasaan berikutnya yang belum kita butuhkan: sampai di sini seluruh migrasi kita murni additif — menambah tabel dan index tanpa pernah menyentuh yang lama. Suatu saat kebutuhan muncul mengganti nama kolom atau memecah tabel, dan perubahan destruktif seperti itu tidak boleh berada dalam satu deploy dengan kode yang membacanya. Pola expand-contract memecahnya menjadi dua deploy berjeda waktu: fase expand menambah struktur baru tanpa mengganggu yang lama, fase contract membersihkan yang lama setelah tidak ada kode memakainya — pembahasan lengkap dengan contoh pemeriksanya ada di chapter Database Deployment . Pada aplikasi multi-tenant, disiplin ini bukan kemewahan: satu langkah contract yang terlalu cepat menghapus kolom yang masih dibaca berarti memutus semua tenant pada saat yang sama.

Verifikasi Tanpa Docker #

Satu hal perlu kita akui jujur sejak awal: Docker tidak terpasang di lingkungan kursus ini, sehingga docker build tidak bisa dijalankan dan direkam di halaman ini. Bagian yang memang butuh Docker sungguhan kita tuliskan sebagai perintah siap jalankan di mesin Anda — build dilakukan dari folder aplikasi-saas-multi-tenant/ karena konteks build menentukan apa yang bisa di-COPY:

bash
cd aplikasi-saas-multi-tenant
docker build -f bagian-7-deployment-dan-monitoring/Dockerfile -t saas:kursus .

Menjalankan container verifikasi siap dengan database di volume bernama — volume membuat data seluruh tenant bertahan melewati hidup-mati container:

bash
docker run --rm \
    -v saas-data:/data \
    saas:kursus

Menimpa konfigurasi untuk lingkungan berbeda tanpa membangun ulang image:

bash
docker run --rm \
    -e DB_PATH=/data/staging.sqlite \
    -v saas-data:/data \
    saas:kursus

Yang bisa kami buktikan di lingkungan ini adalah seluruh logika yang dijalankan container. Kami menyalin payload php -r pada CMD byte-per-byte dari Dockerfile, lalu menjalankannya dengan PHP 8.4.23 lokal terhadap tata letak yang identik dengan isi image (/app/schema.sql, /app/migrasi-billing.sql, /app/migrasi-audit-jobs.sql, dan /app/src/ berisi kedelapan class) dengan DB_PATH disuntik lewat environment. Hasilnya: exit code 0, dua baris jumlah proyek per tenant, dan baris saas siap. Menjalankannya untuk kedua kalinya pada database yang sama memberi keluaran yang sama persis — bukti start yang idempoten. Baris HEALTHCHECK kami uji dengan cara yang sama: exit 0 pada database lengkap, dan exit 1 pada database yang hanya memuat schema dasar. Demo berikut adalah versi yang bisa Anda jalankan langsung dari folder bagian ini, dengan class dimuat lewat path antar bagian:

php
<?php

declare(strict_types=1);

require __DIR__ . '/../bagian-2-tenant-isolation/src/TenantContext.php';
require __DIR__ . '/../bagian-2-tenant-isolation/src/TenantRepository.php';
require __DIR__ . '/../bagian-3-membership-dan-permissions/src/MembershipRepository.php';
require __DIR__ . '/../bagian-3-membership-dan-permissions/src/AccessControl.php';
require __DIR__ . '/../bagian-4-billing-dan-subscriptions/src/PlanRepository.php';
require __DIR__ . '/../bagian-4-billing-dan-subscriptions/src/SubscriptionService.php';
require __DIR__ . '/../bagian-5-audit-dan-background-jobs/src/AuditLogger.php';
require __DIR__ . '/../bagian-5-audit-dan-background-jobs/src/JobQueue.php';

putenv('DB_PATH=saas-demo.sqlite');

/**
 * Satu "start container": logika yang sama dengan CMD Dockerfile.
 *
 * Di dalam image, kedelapan class berada rata di src/ sehingga CMD
 * cukup memakai glob('src/*.php'); di sini kita memuatnya lewat
 * path antar bagian.
 */
function startContainer(): void
{
    $pdo = new PDO('sqlite:' . getenv('DB_PATH'));
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

    // Schema dasar HANYA bila tabel tenants belum ada - seed-nya
    // tidak idempoten, menjalankannya dua kali menggandakan data.
    $siap = (int) $pdo->query(
        "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='tenants'"
    )->fetchColumn();
    if ($siap === 0) {
        $pdo->exec((string) file_get_contents(
            __DIR__ . '/../bagian-1-tenant-dan-domain-model/schema.sql'
        ));
    }

    // Dua migrasi idempoten: selalu aman dijalankan setiap start.
    foreach ([
        __DIR__ . '/../bagian-4-billing-dan-subscriptions/migrasi-billing.sql',
        __DIR__ . '/../bagian-5-audit-dan-background-jobs/migrasi-audit-jobs.sql',
    ] as $migrasi) {
        $pdo->exec((string) file_get_contents($migrasi));
    }

    // Smoke: hitung proyek per tenant lewat query ter-scope.
    foreach ($pdo->query('SELECT id FROM tenants ORDER BY id')->fetchAll(PDO::FETCH_COLUMN) as $id) {
        $hitung = $pdo->prepare('SELECT COUNT(*) FROM projects WHERE tenant_id = ?');
        $hitung->execute([$id]);
        echo 'tenant #' . $id . ': ' . $hitung->fetchColumn() . ' proyek' . PHP_EOL;
    }
    echo 'saas siap: ' . getenv('DB_PATH') . PHP_EOL;
}

echo "== Start 1: volume baru ==\n";
startContainer();

echo "\n== Start 2: volume yang sama, setelah restart ==\n";
startContainer();

unlink(getenv('DB_PATH'));

Output yang Dihasilkan:

text
== Start 1: volume baru ==
tenant #1: 2 proyek
tenant #2: 3 proyek
saas siap: saas-demo.sqlite

== Start 2: volume yang sama, setelah restart ==
tenant #1: 2 proyek
tenant #2: 3 proyek
saas siap: saas-demo.sqlite

Dua start itu memerankan dua kehidupan container pada volume yang sama. Start 1 menemukan database kosong: schema dasar dipasang lengkap dengan seed-nya, kedua migrasi menyusul, dan smoke mencetak 2 proyek milik tenant #1 serta 3 milik tenant #2. Start 2 menemukan tabel tenants sudah ada — gerbang membuat schema dasar dilewati, seed tidak digandakan, kedua migrasi dijalankan ulang tanpa efek samping, dan angka per tenant tetap sama. Perhatikan juga baris terakhir disiplin isolasi: bahkan smoke test operator menghitung proyek per tenant lewat WHERE tenant_id = ?, bukan satu query telanjang tanpa filter — satu-satunya query tanpa penyaringan yang pernah kita cetak di seluruh proyek adalah demo kebocoran yang disengaja pada Bagian 2.

Monitoring Khas Aplikasi SaaS #

Dashboard aplikasi biasa menjawab “apakah aplikasi sehat?” lewat uptime, laju error, dan latensi. Aplikasi SaaS butuh jawaban untuk pertanyaan yang lebih tajam: “tenant mana yang sedang menderita?”. Rata-rata hijau bisa menyembunyikan satu tenant yang terkunci di batas kuota, satu tenant yang meledak audit-nya, atau satu tenant yang job-nya tak kunjung diproses. Karena itu metrik SaaS hampir selalu berdimensi tenant — dan di produksi bentuknya adalah gauge berlabel, misalnya saas_kuota_proyek_persen{tenant="toko-bunga-melati"}, yang diekspor untuk Prometheus sebagaimana dibahas di chapter Application Metrics . Kita bangun versi paling intinya sebagai fungsi murni yang bisa diuji:

php
<?php

declare(strict_types=1);

require __DIR__ . '/../bagian-4-billing-dan-subscriptions/src/PlanRepository.php';
require __DIR__ . '/../bagian-4-billing-dan-subscriptions/src/SubscriptionService.php';

/**
 * Metrik pemakaian kuota proyek per tenant.
 *
 * Angka-angka ini menjawab pertanyaan operasional SaaS: tenant mana
 * yang mendekati batas plan-nya - calon pelanggan upgrade, atau
 * calon kasus blokir - sebelum ada yang mengeluh. Penghitungan
 * proyek memakai query ter-scope tenant_id, dan waktu dibekukan
 * pemanggil sehingga snapshot selalu bisa direproduksi.
 *
 * @return list<array<string, int|string>> slug, plan, terpakai, batas, persen
 */
function metrikKuotaProyek(PDO $pdo, DateTimeImmutable $now): array
{
    $subscriptions = new SubscriptionService($pdo, new PlanRepository($pdo));

    $baris = [];
    foreach ($pdo->query('SELECT id, slug FROM tenants ORDER BY id') as $tenant) {
        $hitung = $pdo->prepare('SELECT COUNT(*) FROM projects WHERE tenant_id = ?');
        $hitung->execute([$tenant['id']]);
        $terpakai = (int) $hitung->fetchColumn();

        $status = $subscriptions->status((int) $tenant['id'], $now);
        $cariPlan = $pdo->prepare('SELECT plan_kode FROM subscriptions WHERE tenant_id = ?');
        $cariPlan->execute([$tenant['id']]);
        $plan = ($status === 'aktif' || $status === 'tenggang')
            ? (string) $cariPlan->fetchColumn()
            : 'free';
        $batas = $subscriptions->batasProyekEfektif((int) $tenant['id'], $now);

        $baris[] = [
            'slug'     => (string) $tenant['slug'],
            'plan'     => $plan,
            'terpakai' => $terpakai,
            'batas'    => $batas,
            'persen'   => (int) floor($terpakai * 100 / max($batas, 1)),
        ];
    }

    return $baris;
}

function buatDatabaseBilling(): PDO
{
    $pdo = new PDO('sqlite::memory:');
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    $pdo->exec('PRAGMA foreign_keys = ON');
    $pdo->exec((string) file_get_contents(__DIR__ . '/../bagian-1-tenant-dan-domain-model/schema.sql'));
    $pdo->exec((string) file_get_contents(__DIR__ . '/../bagian-4-billing-dan-subscriptions/migrasi-billing.sql'));

    return $pdo;
}

$cetak = function (string $judul, array $metrik): void {
    echo "== $judul ==\n";
    foreach ($metrik as $m) {
        $penanda = $m['persen'] >= 100 ? '  <- PENUH' : ($m['persen'] >= 80 ? '  <- mendekati batas' : '');
        printf(
            "  %-24s %-4s %2d/%-3d %3d%%%s\n",
            $m['slug'],
            $m['plan'],
            $m['terpakai'],
            $m['batas'],
            $m['persen'],
            $penanda
        );
    }
};

$pdo = buatDatabaseBilling();
$now = new DateTimeImmutable('2026-02-01 09:00:00', new DateTimeZone('Asia/Jakarta'));

$cetak('Snapshot 2026-02-01 09:00 WIB', metrikKuotaProyek($pdo, $now));

$subscriptions = new SubscriptionService($pdo, new PlanRepository($pdo));
$subscriptions->subscribe(1, 'pro', $now);

echo "\n";
$cetak('Setelah tenant #1 upgrade ke plan pro', metrikKuotaProyek($pdo, $now));

Output yang Dihasilkan:

text
== Snapshot 2026-02-01 09:00 WIB ==
  toko-bunga-melati        free  2/3    66%
  studio-foto-cendrawasih  free  3/3   100%  <- PENUH

== Setelah tenant #1 upgrade ke plan pro ==
  toko-bunga-melati        pro   2/25    8%
  studio-foto-cendrawasih  free  3/3   100%  <- PENUH

Snapshot pertama membaca kondisi seed tanpa langganan: Toko Bunga Melati memakai 2 dari 3 proyek plan gratis (66%), Studio Foto Cendrawasih sudah 3 dari 3 (100%, PENUH). Setelah Budi meng-upgrade tenant #1 ke plan Pro pada jam yang sama, batasnya melompat ke 25 dan persentase jatuh ke 8% — sementara tenant #2 tidak tersentuh sama sekali, persis invarian yang dijaga test Bagian 6. Fungsi ini memperlihatkan tiga keputusan yang membuatnya layak jadi metrik: fungsi murni tanpa efek samping; jam dibekukan pemanggil sehingga snapshot apa pun bisa direproduksi dan diuji; dan logika batas tidak menulis ulang aturan SubscriptionService melainkan memanggilnya — satu sumber kebenaran untuk “batas siapa”. Ambang penanda mendekati batas pada 80% adalah calon alert produksi: tiga perlima penuh adalah waktu yang tepat untuk email penawaran upgrade, bukan pagi setelah pengguna diblokir.

Dua metrik SaaS lainnya hidup di sekitar tabel Bagian 5 dan tidak butuh kode baru untuk mulai dipantau. Pertama, pertumbuhan audit trail: audit_log append-only bertambah selamanya menurut desainnya, jadi yang dipantau adalah laju pertumbuhan per tenant — COUNT(*) ter-scope tenant_id dari waktu ke waktu — untuk memutuskan kapan arsip atau kebijakan retensi perlu masuk. Setiap baris membawa tenant_id, sehingga pertumbuhan selalu bisa diatribusikan: tenant yang audit-nya membengkak lima kali lipat adalah pertanyaan bisnis, bukan misteri teknis. Kedua, kedalaman antrean: jumlah job menunggu yang sudah lama jatuh tempo, job diproses yang menggantung, dan terutama dead letter — deadLetters() sudah mengembalikan tenant_id dan error per baris, siap untuk triase per tenant. Antrean juga soal keadilan: ledakan job dari satu tenant tidak boleh menunda seluruh antrean milik semua orang, dan kedalaman per tenant adalah angka yang membuat ketimpangan itu terlihat — pembahasan pola antreannya ada di chapter Job Queues .

Dan ada satu “metrik” yang paling murah sekaligus paling penting: test regresi isolasi sebagai gerbang CI. Suite lima belas test Bagian 6 berjalan dalam setengah detik dan berakhir dengan exit code — angka yang dibaca pipeline untuk menahan commit yang merusak isolasi sebelum menyentuh produksi, sebagaimana dibahas di chapter Continuous Integration . Monitoring mendeteksi kebocoran setelah lepas; gerbang CI mencegahnya naik. Pada aplikasi yang blast radius-nya seluruh pelanggan, kedua lapisan itu sama-sama wajib.

Health Check yang Menguji Struktur #

Health check adalah kontrak paling sederhana antara aplikasi dan pengelolanya: satu pertanyaan — “bersediakah proses ini melayani?” — dijawab dengan exit code atau kode HTTP. Perbedaan liveness dan readiness, serta cara load balancer mengonsumsinya, dibahas penuh di chapter Health Checks . Pertanyaan desainnya di sini: apa yang layak diperiksa? Jawaban murah “bisa buka koneksi” tidak cukup untuk aplikasi ini, karena mode kegagalan yang paling khas justru struktur setengah terpasang: migrasi billing gagal di tengah jalan, container tetap hidup, koneksi tetap bisa dibuka — dan setiap request pengguna gagal pada tabel yang tidak ada. Health check yang baik untuk aplikasi ini harus menguji daftar tabel yang diharapkan lengkap:

php
<?php

declare(strict_types=1);

/**
 * Periksa kesehatan struktur database aplikasi SaaS.
 *
 * Daftar tabel wajib = schema dasar Bagian 1 + kedua migrasi (Bagian
 * 4 dan 5). Health check yang hanya bertanya "bisakah koneksi
 * dibuka" akan meloloskan deployment setengah jalan; membandingkan
 * daftar lengkap membuat migrasi yang gagal terlihat sebagai TIDAK
 * SEHAT sebelum satu pun request pengguna menyentuhnya.
 *
 * @return array{sehat: bool, hilang: list<string>, pesan: string}
 */
function cekKesehatanDatabase(PDO $pdo): array
{
    $tabelWajib = [
        'tenants', 'users', 'memberships', 'projects',
        'plans', 'subscriptions', 'audit_log', 'job_queue',
    ];

    $ada = $pdo->query(
        "SELECT name FROM sqlite_master WHERE type='table'"
    )->fetchAll(PDO::FETCH_COLUMN);
    $hilang = array_values(array_diff($tabelWajib, $ada));

    if ($hilang !== []) {
        return [
            'sehat'  => false,
            'hilang' => $hilang,
            'pesan'  => 'deployment setengah jalan, tabel hilang: ' . implode(', ', $hilang),
        ];
    }

    // Probe baca ringan: tabel yang ada belum tentu terbaca (berkas
    // rusak, disk penuh) - satu query murah per tabel menutup celah
    // itu tanpa membaca satu baris data pun.
    foreach ($tabelWajib as $tabel) {
        $pdo->query("SELECT 1 FROM {$tabel} LIMIT 1");
    }

    return ['sehat' => true, 'hilang' => [], 'pesan' => 'kedelapan tabel lengkap dan terbaca'];
}

function buatKoneksi(): PDO
{
    $pdo = new PDO('sqlite::memory:');
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    $pdo->exec('PRAGMA foreign_keys = ON');

    return $pdo;
}

$skema = __DIR__ . '/../bagian-1-tenant-dan-domain-model/schema.sql';
$migrasi = [
    __DIR__ . '/../bagian-4-billing-dan-subscriptions/migrasi-billing.sql',
    __DIR__ . '/../bagian-5-audit-dan-background-jobs/migrasi-audit-jobs.sql',
];

echo "== Deployment setengah jalan: hanya schema dasar ==\n";
$setengah = buatKoneksi();
$setengah->exec((string) file_get_contents($skema));
$hasil = cekKesehatanDatabase($setengah);
printf("  sehat: %s - %s\n", $hasil['sehat'] ? 'YA' : 'TIDAK', $hasil['pesan']);

echo "\n== Deployment lengkap: schema + kedua migrasi ==\n";
$lengkap = buatKoneksi();
$lengkap->exec((string) file_get_contents($skema));
foreach ($migrasi as $berkas) {
    $lengkap->exec((string) file_get_contents($berkas));
}
$hasil = cekKesehatanDatabase($lengkap);
printf("  sehat: %s - %s\n", $hasil['sehat'] ? 'YA' : 'TIDAK', $hasil['pesan']);

Output yang Dihasilkan:

text
== Deployment setengah jalan: hanya schema dasar ==
  sehat: TIDAK - deployment setengah jalan, tabel hilang: plans, subscriptions, audit_log, job_queue

== Deployment lengkap: schema + kedua migrasi ==
  sehat: YA - kedelapan tabel lengkap dan terbaca

Database pertama memuat hanya schema dasar — empat tabel inti ada, koneksi berhasil, dan justru itulah jebakannya: cekKesehatanDatabase() menolaknya karena empat tabel migrasi hilang, lengkap dengan daftarnya (plans, subscriptions, audit_log, job_queue) sehingga operator tahu persis migrasi mana yang tidak selesai. Database kedua yang lengkap lulus dengan probe baca SELECT 1 ... LIMIT 1 per tabel — query yang tidak pernah menyentuh baris data, murah untuk dipanggil tiap 30 detik, namun tetap menutup kasus berkas rusak. Nilai kembalian berbentuk struktur (sehat, hilang, pesan) agar pemanggil memilih: Docker HEALTHCHECK hanya butuh exit code, endpoint /healthz produksi bisa memetakan hilang ke body JSON, dan test bisa meng-assert keduanya.

Baris HEALTHCHECK pada Dockerfile adalah versi ringkas dari fungsi ini dalam satu php -r: daftar kedelapan tabel yang sama, probe baca yang sama, dan try/catch yang mengubah segala kegagalan menjadi exit 1 — bukan crash 255, karena health check yang crash adalah laporan yang tidak bisa dibaca. Opsi --interval=30s --timeout=5s --start-period=5s mengatur rituanya: diperiksa tiap 30 detik, batas 5 detik per pemeriksaan, dan tenggang 5 detik di awal hidup container sebelum kegagalan pertama dihitung. Kita jujur sekaligus: pada container smoke yang sekali-jalan, health check tidak sempat dievaluasi — instruksinya baru bermakna ketika CMD diganti entry point berumur panjang. Memasangnya sejak sekarang berarti pilihan itu tinggal mengganti satu baris.

Backup dan Pemulihan Per Tenant #

Backup SQLite dimulai dari satu fakta bentuk fisiknya: seluruh database adalah satu berkas. Menyalin berkas itu dengan cp saat aplikasi sedang menulis berisiko menangkap keadaan sobek — setengah transaksi di sini, setengah di sana. Dua jalur aman yang resmi: perintah .backup pada sqlite3 CLI dan Backup API yang memakai koneksi terbuka untuk menyalin konsisten, keduanya didokumentasikan di sqlite.org/backup.html ; alternatif modernnya VACUUM INTO 'berkas.sqlite' yang menghasilkan salinan terkompresi rapi dalam satu statement. Jadwal backup juga bagian dari ritme deploy — kebiasaan cadangan sebelum migrasi berjalan dibahas pada chapter Database Deployment — dan pada image kita, /data cukup dijadikan target volume agar database hidup terpisah dari hidup-mati container.

Pertanyaan yang membedakan SaaS datang setelah backup ada: “pulihkan data tenant A ke kondisi kemarin”. Pada arsitektur database-per-tenant, jawabannya sederhana — ganti berkas milik tenant itu. Pada shared-database pilihan kita di Bagian 1, granularitas restore bawaan adalah seluruh database: mengembalikan titik waktu kemarin mengembalikan semua tenant ke kemarin, termasuk yang tidak minta. Restore per tenant tetap mungkin, tapi sebagai prosedur tiga langkah: pulihkan salinan ke database sementara, ekstrak baris milik tenant tersebut (persis karena setiap tabel membawa tenant_id, baris mana pun milik siapa selalu bisa ditentukan), lalu replay perubahan itu ke produksi. Satu aturan menjaga kejujurannya: audit_log append-only tidak boleh ditulis ulang oleh prosedur pemulihan — trigger sudah memblokir UPDATE dan DELETE — sehingga restore yang benar menyisipkan baris audit baru yang mencatat peristiwa pemulihan itu sendiri, bukan menghapus jejaknya.

Pilihan arsitektur Bagian 1 memang kompromi yang disadari: shared-database menukar granularitas pemulihan dengan kemudahan operasional — satu schema untuk dimigrasi, satu koneksi untuk dipantau, satu backup untuk dijaga. Database-per-tenant membeli kebalikannya dengan biaya N database yang masing-masing harus dimigrasi, dimonitor, dan dicadangkan. Kompensasi kita adalah hal-hal yang sudah berdiri di bagian-bagian sebelumnya: tenant_id di semua tabel membuat ekstraksi per tenant selalu mungkin, audit trail merekam siapa mengubah apa, dan test isolasi menjaga batas yang membuat ekstraksi itu tidak pernah membocorkan tetangga.

Checklist Kesiapan Produksi #

Bagian penutup proyek adalah saat yang tepat memeriksa ulang seluruh tumpukan dari sudut pandang “siapkah ini melayani tenant nyata”:

PeriksaBukti dari proyek ini
Setiap tabel data tenant membawa tenant_idAturan Bagian 1 tanpa pengecualian — plans adalah satu-satunya tabel konfigurasi global
Context hanya untuk anggota, aksi tak dikenal ditolakTenantContext::fromUser() mengembalikan null untuk non-anggota; matriks izin deny-by-default (Bagian 2-3)
Akses tunggal butuh syarat ganda id + tenant_idPola findForTenant() Bagian 2, diuji sebagai IDOR null pada Bagian 6
Batas dan status plan tidak menular antar tenantbolehBuatProyek() per tenant; upgrade tenant #1 tidak mengangkat tenant #2 (Bagian 4, diuji Bagian 6)
Jejak keputusan tak bisa ditulis ulangaudit_log append-only + dua trigger RAISE(ABORT) (Bagian 5)
Job membawa konteks tenant sampai matijob_queue.tenant_id + deadLetters() per tenant (Bagian 5)
Isolasi dijaga mesin, bukan mataOK (15 tests, 72 assertions) pada Bagian 6, exit code siap dibaca CI
Deploy berulang tanpa merusak dataStart container idempoten: schema digerbangi, migrasi aman diulang (Bagian 7)
Konfigurasi terpisah dari kode, proses non-rootDB_PATH lewat environment variable; USER www-data di Dockerfile (Bagian 7)

Checklist umum aplikasi PHP — dari opcache sampai graceful shutdown — tersedia pada chapter Production Readiness Checklist ; tabel di atas adalah versinya yang terjawab langsung oleh kode tujuh bagian yang baru saja kita tulis.

Apa yang Berubah #

Proyek mendapatkan wujud akhirnya sebagai paket yang bisa dipindahkan:

  • Berkas Dockerfile baru: base php:8.4-cli-alpine dengan pdo_sqlite terbundel, ENV DB_PATH sebagai satu-satunya tombol lingkungan, tiga berkas SQL dan kedelapan class disalin merata ke /app, proses berjalan sebagai www-data non-root, HEALTHCHECK yang menguji kedelapan tabel, dan CMD smoke test dengan gerbang schema serta urutan migrasi yang dijaga.
  • Bukti urutan migrasi: schema → billing → audit+jobs memasang 8 tabel + 2 trigger; urutan terbalik lolos saat dimuat namun gagal pada operasi billing pertama; dua migrasi idempoten; seed schema dasar tidak — alasan gerbang pada CMD.
  • Simulasi start container idempoten: start kedua pada volume yang sama menghasilkan keluaran identik tanpa seed berganda.
  • Fungsi metrikKuotaProyek() baru di halaman: metrik pemakaian kuota per tenant yang menyusun SubscriptionService, jam beku, dan query ter-scope — snapshot yang sama selalu bisa direproduksi.
  • Fungsi cekKesehatanDatabase() baru di halaman: health check berbasis daftar tabel wajib yang menangkap deployment setengah jalan, dipetakan ke baris HEALTHCHECK pada Dockerfile.
  • Tidak ada satu baris class produksi yang berubah — deployment dan monitoring murni lapisan pengemasan di atas kode yang sudah teruji.

Rekap Tujuh Bagian #

Satu proyek selesai; tujuh bagian itu saling menjaga sebagai satu tumpukan:

BagianYang dibangunBekalnya bagi bagian ini
1. Tenant dan Domain Modelschema.sql shared-database, PK komposit memberships, seed bekuStruktur yang digerbangi setiap start; tenant_id di semua tabel
2. Tenant IsolationTenantContext immutable, query ter-scope syarat gandaDisiplin yang dipegang sampai smoke test terakhir
3. Membership dan PermissionsMembershipRepository, matriks izin AccessControlPeta siapa-boleh-apa yang dijaga test regresi
4. Billing dan Subscriptionsmigrasi-billing.sql, status empat tahap, jam bekuBatas per tenant yang dibaca metrik kuota
5. Audit dan Background Jobsaudit_log append-only, JobQueue dengan retryDua sinyal operasional: pertumbuhan audit dan kedalaman antrean
6. Security dan TestingSuite 15 test isolasi, TenantContext::clear() antar testGerbang CI yang menahan regresi sebelum produksi
7. Deployment dan MonitoringImage Docker, urutan migrasi, metrik kuota, health strukturKemasan yang membuat semuanya bisa pindah dan diawasi

Dari schema.sql pertama sampai health check terakhir, pola yang sama berulang: satu keputusan desain diambil sadar di awal, lalu dijaga berlapis — constraint database, aturan domain di service, test yang mencoba menembusnya, dan akhirnya deployment yang menolak berjalan dalam keadaan setengah jalan. Kedua tenant kita, Toko Bunga Melati dan Studio Foto Cendrawasih, tidak pernah melihat data satu sama lain sedetik pun — dan sekarang ada mesin yang memastikannya tetap begitu setiap kali kode berpindah tempat. Proyek penutup course ini, Library PHP Reusable , mengubah sudut pandang untuk terakhir kali: dari menyervis banyak tenant menjadi menulis pustaka untuk sesama pengembang — di sana API publik yang minimal, dokumentasi yang jujur, dan test sebagai kontrak, semua disiplin yang baru saja kita latih, dibawa ke ranah yang sama sekali berbeda.

Referensi #