Tujuh bagian yang lalu proyek ini dimulai dari sebuah berkas schema.sql; kini di atasnya berdiri repository, service transaksional, validator, laporan, pencarian, dan sembilan test yang berjaga. Satu pertanyaan terakhir belum terjawab: apakah semua ini berjalan identik di komputer lain? Versi PHP yang berbeda, ekstensi yang lupa terpasang, atau lokasi konfigurasi yang tidak sama bisa membuat kode yang lulus test di laptop kita gagal di server. Pada bagian penutup ini kita jawab dengan mengemas seluruh proyek ke dalam satu image Docker — runtime PHP, keenam class, dan schema ikut serta — dikonfigurasi lewat environment variable DB_PATH, dengan migrasi yang aman dijalankan setiap start dan health check yang siap untuk entry point produksi. Ditutup dengan checklist kesiapan produksi yang merangkum tujuh bagian perjalanan.
Dari Kode ke Image #
Docker menyelesaikan masalah parity dengan mengemas aplikasi bersama runtime-nya menjadi satu image yang berjalan identik di komputer mana pun, plus isolation antar aplikasi pada satu host — pembahasan lengkapnya ada pada chapter Docker dan PHP
. Untuk proyek ini, image yang kita bangun bertumpu pada php:8.4-cli-alpine: varian CLI karena seluruh kode kita berjalan di command line, basis Alpine karena ukurannya kecil, dan versi 8.4 terkunci agar perilaku build konsisten.
Ada satu hal yang perlu kita akui dengan jujur sejak awal: Docker tidak terpasang di lingkungan kursus ini, jadi perintah docker build tidak bisa kita jalankan dan rekam di halaman ini. Yang kita lakukan adalah hal yang bisa dibuktikan: seluruh logika yang dijalankan container — resolusi environment variable, migrasi, sampai satu baris php -r pada CMD — diuji byte-per-byte dengan PHP 8.4.23 lokal terhadap tata letak berkas yang identik dengan isi image. Bagian yang memang butuh Docker sungguhan (build, run, mount volume) kita tuliskan sebagai perintah yang siap Anda jalankan di mesin yang memilikinya.
Konfigurasi Lewat Environment Variable #
Aturan pertama pengemasan: satu image untuk semua lingkungan. Yang membedakan development, staging, dan produksi bukan isi image, melainkan konfigurasi yang disuntik dari luar — dan kanal resminya adalah environment variable, pola yang dibahas pada chapter Environment Variables
. Untuk proyek ini satu variable cukup: DB_PATH, lokasi berkas database SQLite. Fungsi kecil berikut adalah satu-satunya titik resolusinya:
<?php
/**
* Resolve path database dari nilai environment DB_PATH.
*
* Nilai null atau string kosong jatuh ke default sehingga container
* tetap berjalan meskipun variable lupa diisi; nilai apa pun yang
* valid dipakai apa adanya.
*/
function dbPathDariLingkungan(?string $nilaiDbPath): string
{
$path = trim((string) $nilaiDbPath);
return $path === '' ? '/data/inventaris.sqlite' : $path;
}
$cetak = function (string $label, ?string $nilai): void {
printf(" %-32s -> %s\n", $label, dbPathDariLingkungan($nilai));
};
echo "== Tiga kondisi input ==\n";
$cetak('DB_PATH=/data/toko.sqlite', '/data/toko.sqlite');
$cetak('DB_PATH berisi spasi saja', ' ');
$cetak('DB_PATH tidak diset (null)', null);
echo "\n== Pembacaan sungguhan lewat getenv ==\n";
putenv('DB_PATH=/data/cabang-2.sqlite');
$cetak('setelah putenv(DB_PATH=...)', getenv('DB_PATH') ?: null);
putenv('DB_PATH');
$cetak('setelah DB_PATH dihapus', getenv('DB_PATH') ?: null);Output yang Dihasilkan:
== Tiga kondisi input ==
DB_PATH=/data/toko.sqlite -> /data/toko.sqlite
DB_PATH berisi spasi saja -> /data/inventaris.sqlite
DB_PATH tidak diset (null) -> /data/inventaris.sqlite
== Pembacaan sungguhan lewat getenv ==
setelah putenv(DB_PATH=...) -> /data/cabang-2.sqlite
setelah DB_PATH dihapus -> /data/inventaris.sqliteTiga kondisi input memperlihatkan kontraknya: nilai valid dipakai apa adanya, sedangkan null maupun spasi kosong jatuh ke default /data/inventaris.sqlite — kegagalan mengisi variable tidak boleh membuat container mati dengan cara yang membingungkan. Blok kedua membuktikan resolusi sungguhan lewat getenv(): putenv() mengisi variable dalam proses yang sama (perintah docker run -e DB_PATH=... adalah padanannya di dunia container), dan getenv('DB_PATH') ?: null menormalkan kembalian false saat variable tidak ada menjadi null yang fungsi kita pahami. Pada Dockerfile nanti, ENV DB_PATH memberi nilai default pada level image sehingga resolusi di runtime selalu punya pijakan.
Migrasi Database Saat Deploy #
Saat container baru start di server, database di DB_PATH bisa berupa berkas kosong yang baru dibuat volume. Struktur harus diterapkan sebelum class mana pun dipakai — dan harus aman bila dijalankan lagi pada deploy berikutnya. Di sinilah keputusan Bagian 1 terbayar: schema.sql memang dirancang idempoten sejak awal. CREATE TABLE IF NOT EXISTS melewati tabel yang sudah ada, INSERT OR IGNORE melewati seed yang kolom UNIQUE-nya sudah terisi, dan blok seed movement hanya berjalan bila buku besar masih kosong. Migrasi kita kini sesederhana satu exec:
<?php
/**
* Terapkan schema ke database; aman dipanggil berulang (idempoten).
*/
function jalankanMigrasi(PDO $pdo, string $berkasSchema): void
{
$pdo->exec((string) file_get_contents($berkasSchema));
}
function hitungProduk(PDO $pdo): int
{
return (int) $pdo->query('SELECT COUNT(*) FROM products')->fetchColumn();
}
putenv('DB_PATH=inventaris-demo.sqlite');
echo "== Deploy 1: database baru di DB_PATH ==\n";
$pdo = new PDO('sqlite:' . getenv('DB_PATH'));
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
jalankanMigrasi($pdo, __DIR__ . '/../bagian-1-desain-database/schema.sql');
printf(" %s berisi %d produk\n", getenv('DB_PATH'), hitungProduk($pdo));
echo "\n== Deploy 2: schema dijalankan ulang ==\n";
jalankanMigrasi($pdo, __DIR__ . '/../bagian-1-desain-database/schema.sql');
printf(" %s tetap %d produk - idempoten, tanpa duplikat\n", getenv('DB_PATH'), hitungProduk($pdo));
echo "\n== Health check versi HEALTHCHECK ==\n";
$sehat = $pdo->query('SELECT COUNT(*) FROM products') !== false;
echo $sehat ? " exit 0 - tabel products terbaca\n" : " exit 1 - database bermasalah\n";
unset($pdo);
unlink(getenv('DB_PATH'));Output yang Dihasilkan:
== Deploy 1: database baru di DB_PATH ==
inventaris-demo.sqlite berisi 6 produk
== Deploy 2: schema dijalankan ulang ==
inventaris-demo.sqlite tetap 6 produk - idempoten, tanpa duplikat
== Health check versi HEALTHCHECK ==
exit 0 - tabel products terbacaDeploy pertama membuat database lengkap dengan 6 produk dan 9 movement awal; deploy kedua menjalankan schema yang sama dan jumlah produk tetap 6 — tidak ada duplikat, tidak ada error. Frame kerja seperti Laravel menyelesaikan hal yang sama dengan migrasi terversi (php artisan migrate mencatat nomor migrasi yang sudah berjalan); proyek ini mencapai jaminan yang setara — aman dijalankan pada setiap deploy — lewat idempotensi di dalam berkas schema itu sendiri. Blok terakhir adalah isi health check versi PHP: satu query kecil yang membuktikan database terbuka dan tabel utama terbaca, yang nanti kita pasang sebagai instruksi HEALTHCHECK pada Dockerfile.
Dockerfile #
Berikut isi lengkap Dockerfile pada bundle bagian ini:
# Dockerfile - image sistem inventaris (Bagian 7).
#
# Build dari folder sistem-inventaris/ (konteks build), bukan dari
# dalam folder bagian ini:
# docker build -f bagian-7-deployment/Dockerfile -t inventaris: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/inventaris.sqlite
WORKDIR /app
# Schema (Bagian 1) + seluruh class (Bagian 2-5) disalin merata ke
# /app: schema.sql di root, keenam class di src/.
COPY bagian-1-desain-database/schema.sql ./schema.sql
COPY bagian-2-produk-dan-kategori/src/ ./src/
COPY bagian-3-stock-movements/src/ ./src/
COPY bagian-4-transactions-dan-validation/src/ ./src/
COPY bagian-5-laporan-dan-search/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.
USER www-data
# Health check: buka database di DB_PATH dan pastikan tabel products
# terbaca. Hanya bermakna bila CMD diganti entry point yang berjalan
# lama (worker, server) - pada container sekali-jalan ia tidak sempat
# dievaluasi, tetapi resepnya sudah siap.
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s \
CMD php -r '$pdo = new PDO("sqlite:" . getenv("DB_PATH")); exit($pdo->query("SELECT COUNT(*) FROM products") !== false ? 0 : 1);'
# Image ini memaketkan class library, bukan aplikasi interaktif - proyek
# kursus tidak membangun CLI console. CMD menjalankan verifikasi siap:
# muat seluruh class, terapkan schema (idempoten, aman setiap start),
# lalu laporkan kesiapan lalu selesai. Untuk produksi, ganti titik ini
# dengan entry point nyata: console command, worker antrean, atau
# web root di depan php:8.4-fpm-alpine + Nginx.
CMD ["php", "-r", "foreach (glob('src/*.php') as $f) { require $f; } $pdo = new PDO('sqlite:' . getenv('DB_PATH')); $pdo->exec((string) file_get_contents('schema.sql')); echo 'inventaris siap: ' . getenv('DB_PATH') . PHP_EOL;"]Mari kita bedah baris per baris:
FROM php:8.4-cli-alpinememulai dari image resmi PHP 8.4 varian CLI dengan basis Alpine — runtime yang sama dengan yang dipakai sepanjang kursus ini.ENV DB_PATH=/data/inventaris.sqlitemenetapkan nilai default konfigurasi pada level image;docker run -e DB_PATH=...bisa menimpanya tanpa membangun ulang.WORKDIR /appmenetapkan direktori kerja untukCOPYdanCMDberikutnya.- Empat baris
COPYmenyalinschema.sqlke root/appdan keenam class dari Bagian 2-5 secara merata ke/app/src/— di dalam image tidak ada lagi struktur per bagian, hanya aplikasi utuh. Konteks build-nya adalah foldersistem-inventaris/, bukan folder bagian ini. RUN mkdir -p /data && chown ...menyiapkan rumah bagi database, laluUSER www-datamenjalankan proses sebagai user non-root — bila proses di dalam container berhasil dieksploitasi, penyerang tidak langsung mendapat akses root.HEALTHCHECKmemberi Docker cara memeriksa container lewat kode exit: query kecil membuka database diDB_PATHdan membaca tabelproducts; exit0berarti sehat. Perlu jujur dijelaskan: health check hanya bermakna pada container yang berjalan lama — container kita menjalankan satu perintah lalu selesai, jadi instruksi ini adalah resep yang sudah terpasang untuk saatCMDdiganti entry point sungguhan (worker antrean, server).CMDdalam bentuk arrayexec formmenjalankan verifikasi siap:glob('src/*.php')memuat keenam class (sekaligus parse check — berkas rusak langsung terlihat saat start), schema diterapkan keDB_PATH(idempoten, aman setiap start), lalu container mencetakinventaris siap: ...dan selesai.
Pilihan CMD ini adalah keputusan yang layak dijelaskan terbuka: proyek kursus kita membangun library class beserta test-nya, bukan aplikasi interaktif — tidak ada CLI console atau router HTTP yang bisa kita jadikan proses utama. Maka CMD yang jujur adalah verifikasi siap: container yang start dari image ini membuktikan seluruh class termuat dan database siap, lalu berhenti. Di produksi, titik ini diganti entry point nyata: console command yang menangani masuk/keluar/laporan, worker yang mengonsumsi antrean, atau image turunan php:8.4-fpm-alpine di belakang Nginx sebagaimana dibahas pada chapter Docker dan PHP
.
Verifikasi Tanpa Docker #
Klaim “CMD berjalan” tidak boleh berhenti di klaim. Kami menyalin baris php -r pada CMD byte-per-byte dari Dockerfile, menjalankannya dengan PHP 8.4.23 lokal terhadap tata letak yang identik dengan isi image (/app/schema.sql + /app/src/ berisi keenam class), dengan DB_PATH disuntik lewat environment. Hasilnya: exit code 0, keluaran inventaris siap: ..., dan database berisi tepat 6 produk serta 9 movement — menjalankannya untuk kedua kalinya memberi hasil yang sama persis, bukti idempotensi start. Demo berikut adalah versi yang bisa Anda jalankan langsung dari folder bagian ini, dengan memuat class lewat path antar bagian:
<?php
require __DIR__ . '/../bagian-2-produk-dan-kategori/src/CategoryRepository.php';
require __DIR__ . '/../bagian-2-produk-dan-kategori/src/ProductRepository.php';
require __DIR__ . '/../bagian-3-stock-movements/src/StockMovementRepository.php';
require __DIR__ . '/../bagian-4-transactions-dan-validation/src/InventoryService.php';
require __DIR__ . '/../bagian-4-transactions-dan-validation/src/Validator.php';
require __DIR__ . '/../bagian-5-laporan-dan-search/src/ReportService.php';
putenv('DB_PATH=inventaris-demo.sqlite');
// Logika yang sama dengan CMD pada Dockerfile: muat seluruh class,
// terapkan schema ke DB_PATH, lalu laporkan kesiapan. Di dalam image
// keenam class berada rata di src/ sehingga CMD cukup memakai
// glob('src/*.php'); di sini kita memuatnya lewat path antar bagian.
$pdo = new PDO('sqlite:' . getenv('DB_PATH'));
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec((string) file_get_contents(__DIR__ . '/../bagian-1-desain-database/schema.sql'));
$reports = new ReportService($pdo);
printf(
"inventaris siap: %s (%d produk, nilai %s)\n",
getenv('DB_PATH'),
count($reports->pencarian('')),
'Rp' . number_format($reports->nilaiPersediaan(), 0, ',', '.')
);
unset($pdo);
unlink(getenv('DB_PATH'));Output yang Dihasilkan:
inventaris siap: inventaris-demo.sqlite (6 produk, nilai Rp2.410.000)Baris terakhir output memakai ReportService secara sadar: setelah schema diterapkan, laporan nilai persediaan membaca Rp2.410.000 — angka yang sama dengan walkthrough Bagian 5. Image yang sehat bukan hanya yang class-nya termuat, tetapi yang laporannya benar; verifikasi siap kita pun setara dengan satu assertion test.
Menjalankan di Lingkungan Nyata #
Di mesin yang memiliki Docker, tiga perintah berikut menghidupkan seluruh bagian ini. Build dilakukan dari folder sistem-inventaris/ karena konteks build menentukan apa yang bisa di-COPY:
cd sistem-inventaris
docker build -f bagian-7-deployment/Dockerfile -t inventaris:kursus .Menjalankan container verifikasi siap dengan database di volume bernama — volume membuat database bertahan melewati hidup-mati container:
docker run --rm \
-v inventaris-data:/data \
inventaris:kursusDan menimpa konfigurasi lewat environment variable untuk lingkungan yang berbeda tanpa membangun ulang image:
docker run --rm \
-e DB_PATH=/data/cabang-2.sqlite \
-v inventaris-data:/data \
inventaris:kursusKetiga perintah tersebut menuntaskan siklus yang kita rancang: satu image, konfigurasi dari luar, migrasi otomatis yang aman diulang, dan laporan kesiapan yang tercetak.
Checklist Kesiapan Produksi #
Bagian penutup proyek adalah saat yang tepat untuk memeriksa ulang seluruh tumpukan. Checklist berikut merangkum ketujuh bagian dari sudut pandang “siapkah ini melayani pengguna nyata”:
| Periksa | Bukti dari proyek ini |
|---|---|
| Struktur data dijaga constraint | CHECK, UNIQUE, dan foreign key tanpa CASCADE pada Bagian 1 — penolakan 23000 adalah perilaku yang diinginkan |
| Perubahan stok selalu tercatat | InventoryService sebagai satu-satunya penulis stok_sekarang, dibungkus transaksi (Bagian 4) |
| Input buruk ditolak dengan pesan manusiawi | Validator error bag (Bagian 4) di depan, constraint database di belakang |
| Query pengaman dari injection dan wildcard | prepared statements sejak Bagian 2, escaping LIKE pada Bagian 5 |
| Perilaku dijaga test otomatis | OK (9 tests, 34 assertions) pada Bagian 6, exit code siap dibaca CI |
| Konfigurasi terpisah dari kode | DB_PATH lewat environment variable dengan default yang aman |
| Deploy berulang tanpa merusak data | schema idempoten dijalankan pada setiap start; volume menyimpan database |
Checklist yang lebih lengkap untuk aplikasi PHP umum — dari opcache sampai graceful shutdown — tersedia pada chapter Production Readiness Checklist ; tabel di atas adalah versinya yang terjawab oleh kode yang baru saja kita tulis sendiri.
Apa yang Berubah #
Proyek mendapatkan wujud akhirnya sebagai paket yang siap dipindahkan:
- Berkas
Dockerfilebaru: basephp:8.4-cli-alpine, konfigurasiENV DB_PATH, keenam class danschema.sqldisalin merata ke/app, proses berjalan sebagaiwww-datanon-root,HEALTHCHECKberbasis query kecil, danCMDverifikasi siap yang memuat class, menerapkan migrasi, lalu melaporkan diri. - Fungsi resolusi
DB_PATHdengan default yang aman, teruji pada tiga kondisi input plus pembacaangetenv()sungguhan. - Migrasi saat deploy terbukti idempoten: schema dijalankan dua kali, jumlah produk tetap 6 tanpa duplikat — desain
schema.sqlBagian 1 terbayar penuh. - Verifikasi tanpa Docker tetap jujur dan nyata: baris
CMDdanHEALTHCHECKdiuji byte-per-byte dengan PHP 8.4.23 lokal terhadap tata letak identik image, dan ketiadaan Docker di lingkungan kursus dijelaskan terbuka beserta perintah yang siap dijalankan di mesin Anda. - Tidak ada satu baris class produksi yang berubah — deployment murni lapisan pengemasan di atas kode yang sudah teruji.
Ringkasan #
Bagian ini menutup sistem inventaris dengan menjawab pertanyaan terakhirnya: bukan hanya “benarkah kode ini”, melainkan “akan tetap benarkah ia di tempat lain”. Image Docker membawa runtime dan kode sebagai satu kesatuan, environment variable menjauhkan konfigurasi dari kode, migrasi idempoten membuat setiap deploy aman diulang, dan health check menyiapkan masa depan ketika entry point nyata dipasang. Tujuh bagian yang dimulai dari schema.sql kini berakhir sebagai paket yang bisa dipindahkan — dengan invarian yang dijaga tiga lapis pertahanan, perilaku yang dijaga sembilan test, dan pengemasan yang dijaga desainnya sendiri. Pola lengkap ini — desain data, akses data, invarian transaksional, validasi, laporan, test, deployment — adalah kerangka yang akan Anda pakai lagi dan lagi. Proyek berikutnya, Sistem Autentikasi
, mengangkat satu topik yang sengaja kita lewatkan di sini: user, password yang di-hash, dan sesi login di atas HTTP.