Framework mini kita kini punya router, middleware, controller, template, dan container — tetapi semua nilainya masih tertanam di dalam kode: host database ditulis langsung di repository, mode debug di-hardcode di handler. Setiap kali pindah dari laptop ke server produksi, kode harus diedit ulang; itu resep bencana. Aplikasi yang matang memisahkan kode dari konfigurasi: kode berisi perilaku yang sama di mana pun, konfigurasi berisi nilai yang berbeda per lingkungan. Pada chapter ini kita membangun sistem konfigurasi lengkap: penggabungan berlapis, akses bertitik, override dari environment, validasi saat boot, hingga cache untuk produksi.
Konfigurasi Berlapis: defaults, Berkas, Environment #
Kebutuhan konfigurasi datang dari tiga sumber dengan prioritas naik: nilai bawaan framework, berkas konfigurasi proyek, lalu environment server. Nilai bawaan memastikan aplikasi jalan tanpa pengaturan apa pun; berkas proyek menampung keputusan tim; environment menampung rahasia dan nilai yang spesifik mesin. Proses penggabungannya disebut deep merge — key yang sama ditimpa, key yang berbeda dipertahankan, dan penggabungan berlaku rekursif ke dalam sub-array:
<?php
declare(strict_types=1);
/**
* Menggabungkan dua lapisan konfigurasi secara rekursif.
* Key pada $lapisanAtas menimpa key yang sama pada $basis.
*
* @param array<string, mixed> $basis
* @param array<string, mixed> $lapisanAtas
* @return array<string, mixed>
*/
function configMerge(array $basis, array $lapisanAtas): array
{
foreach ($lapisanAtas as $kunci => $nilai) {
if (is_array($nilai) && isset($basis[$kunci]) && is_array($basis[$kunci])) {
$basis[$kunci] = configMerge($basis[$kunci], $nilai);
continue;
}
$basis[$kunci] = $nilai;
}
return $basis;
}Perhatikan kondisi rekursinya: hanya ketika key yang sama bernilai array di KEDUA sisi, penggabungan turun satu level. Jika nilai lapisan atas bukan array, nilai tersebut menimpa mentah-mentah — itulah yang membuat lapisan atas selalu menang. Alur tiga lapisan tersebut terlihat seperti ini:
Diagram di atas berbunyi sederhana: lapisan bawaan digabung dengan berkas proyek, hasilnya digabung lagi dengan environment, dan hasil akhirnya dipakai aplikasi. Mari kita buktikan dengan data nyata:
<?php
declare(strict_types=1);
require __DIR__ . '/config.php';
// Lapisan 1: nilai bawaan framework
$defaults = [
'db' => [
'host' => '127.0.0.1',
'port' => 3306,
'name' => 'toko_dev',
],
'app' => [
'debug' => false,
'url' => 'https://toko.example.com',
],
];
// Lapisan 2: berkas konfigurasi proyek
$berkas = [
'db' => ['host' => 'db.internal', 'name' => 'toko_prod'],
'app' => ['url' => 'https://toko.example.id'],
];
// Lapisan 3: environment server
$env = [
'db' => ['password' => 'YOUR_DATABASE_PASSWORD'],
];
$config = configMerge(configMerge($defaults, $berkas), $env);
echo json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), "\n";Output yang Dihasilkan:
{
"db": {
"host": "db.internal",
"port": 3306,
"name": "toko_prod",
"password": "YOUR_DATABASE_PASSWORD"
},
"app": {
"debug": false,
"url": "https://toko.example.id"
}
}Baca output tersebut sebagai tiga lapisan yang tergabung: db.host dan db.name berasal dari berkas proyek (menimpa bawaan), db.port tetap 3306 dari bawaan karena berkas proyek tidak menyebutnya, dan db.password muncul sebagai tambahan dari environment. Tidak ada satu nilai pun yang hilang — penggabungan rekursif menjaga semua key dari semua lapisan. PHP sendiri punya fungsi bawaan array_replace_recursive() yang berperilaku sangat mirip; kita menulis configMerge() sendiri agar mekanismenya terlihat, bukan tersembunyi.
Akses Bertitik dengan config #
Array bersarang itu rapi untuk digabungkan tetapi lelah untuk dibaca: $config['db']['host'] dengan tanda kurung siku bertumpuk, dan satu key yang salah ketik memunculkan Warning: Undefined array key. Konvensi framework adalah dot access — satu string path seperti db.host:
<?php
declare(strict_types=1);
final class Config
{
/** @param array<string, mixed> $values */
public function __construct(private readonly array $values) {}
/** Membaca nilai dengan path bertitik, mis. "db.host". */
public function get(string $path, mixed $default = null): mixed
{
$nilai = $this->values;
foreach (explode('.', $path) as $segmen) {
if (!is_array($nilai) || !array_key_exists($segmen, $nilai)) {
return $default;
}
$nilai = $nilai[$segmen];
}
return $nilai;
}
}Method get() memecah path menjadi segmen, lalu berjalan turun level demi level. Kondisi !is_array($nilai) || !array_key_exists(...) berhenti di titik manapun jalur putus, dan nilai bawaan dikembalikan alih-alih memunculkan warning:
<?php
declare(strict_types=1);
require __DIR__ . '/config.php';
$config = new Config([
'db' => ['host' => 'db.internal', 'port' => 3306],
'app' => ['debug' => false, 'url' => 'https://toko.example.id'],
]);
echo 'db.host : ', $config->get('db.host'), "\n";
echo 'db.port : ', $config->get('db.port'), "\n";
echo 'app.debug: ', var_export($config->get('app.debug'), true), "\n";
echo 'db.user : ', var_export($config->get('db.user', 'root'), true), "\n";Output yang Dihasilkan:
db.host : db.internal
db.port : 3306
app.debug: false
db.user : 'root'Tiga baris pertama membaca nilai yang ada; baris terakhir membaca db.user yang tidak ada dan jatuh ke nilai bawaan 'root' tanpa warning. Perhatikan juga hasil var_export() pada app.debug: nilai yang tersimpan tetap boolean false, bukan string — sistem konfigurasi kita tidak merusak tipe data.
Override dari Environment #
Lapisan ketiga adalah environment: variabel seperti DB_HOST yang disetel di server, bukan di berkas proyek. Pola ini penting karena dua alasan. Pertama, rahasia (password, API key) tidak boleh masuk ke repositori kode. Kedua, nilai yang berbeda antar mesin tidak boleh memaksa kode berubah. Dua keputusan teknis menyertainya: variabel environment selalu berupa string, dan mapping nama variabel ke path konfigurasi harus eksplisit:
<?php
declare(strict_types=1);
/** Mengubah nilai string environment menjadi tipe PHP yang tepat. */
function normalizeEnvValue(string $nilai): mixed
{
return match (strtolower($nilai)) {
'true' => true,
'false' => false,
'null' => null,
default => is_numeric($nilai) ? $nilai + 0 : $nilai,
};
}
/**
* Menimpa konfigurasi dengan nilai dari environment.
*
* @param array<string, mixed> $config
* @param array<string, string> $environment
* @param array<string, string> $peta variabel env => path konfigurasi
* @return array<string, mixed>
*/
function overrideDariEnv(array $config, array $environment, array $peta): array
{
foreach ($peta as $variabel => $path) {
if (!array_key_exists($variabel, $environment)) {
continue;
}
$segmen = explode('.', $path);
$referensi = &$config;
foreach ($segmen as $i => $bagian) {
if ($i === array_key_last($segmen)) {
$referensi[$bagian] = normalizeEnvValue($environment[$variabel]);
continue;
}
$referensi[$bagian] ??= [];
$referensi = &$referensi[$bagian];
}
unset($referensi);
}
return $config;
}Dua bagian perlu dibedah. normalizeEnvValue() mengembalikan tipe yang benar: string 'false' dari environment diubah menjadi boolean false, '3307' menjadi integer 3307 — tanpa ini, pemeriksaan if ($config['app']['debug']) akan selalu bernilai true karena string non-kosong adalah truthy. Loop $referensi = &$referensi[$bagian] memakai reference untuk berjalan turun membuat path bertitik yang mungkin belum ada. Mari kita lihat hasilnya:
<?php
declare(strict_types=1);
require __DIR__ . '/config.php';
// Hasil merge berkas — seperti demo pertama
$config = configMerge(
['db' => ['host' => '127.0.0.1', 'port' => 3306], 'app' => ['debug' => false]],
['db' => ['host' => 'db.internal']],
);
// Simulasi environment production
$environment = [
'APP_DEBUG' => 'false',
'DB_HOST' => 'replica.internal',
'DB_PORT' => '3307',
];
$config = overrideDariEnv($config, $environment, [
'APP_DEBUG' => 'app.debug',
'DB_HOST' => 'db.host',
'DB_PORT' => 'db.port',
]);
$cfg = new Config($config);
echo 'db.host : ', $cfg->get('db.host'), "\n";
echo 'db.port : ', $cfg->get('db.port'), ' (', get_debug_type($cfg->get('db.port')), ")\n";
echo 'app.debug: ', var_export($cfg->get('app.debug'), true), "\n";Output yang Dihasilkan:
db.host : replica.internal
db.port : 3307 (int)
app.debug: falseOutput tersebut menegaskan dua hal: environment menang atas berkas (db.host berubah menjadi replica.internal), dan normalisasi tipe bekerja (db.port integer 3307, app.debug boolean false). Dalam aplikasi nyata, nilai environment dibaca dari superglobal $_ENV atau fungsi getenv(), dan tim biasa memakai berkas .env yang TIDAK dikomit ke repositori — pembahasan strategi lengkapnya ada pada chapter Configuration Management
.
Satu Berkas per Komponen #
Konfigurasi satu aplikasi tumbuh besar, jadi framework memecahnya per komponen: app.php, database.php, cache.php. Tiap berkas hanyalah PHP yang return array — format paling kaya yang tetap murah dibaca:
config/
├── app.php
├── database.php
└── cache.phpLoader-nya membaca seluruh berkas *.php dalam direktori dan menjadikan nama berkas sebagai key grup:
<?php
declare(strict_types=1);
final class ConfigLoader
{
/**
* Memuat seluruh berkas *.php dalam daftar direktori; nama berkas
* menjadi key grup (app.php => "app"), isi return-nya menjadi nilai.
*
* @param list<string> $directories
* @return array<string, mixed>
*/
public function load(array $directories): array
{
$config = [];
foreach ($directories as $directory) {
$files = glob($directory . '/*.php') ?: [];
foreach ($files as $file) {
$name = basename($file, '.php');
$config = configMerge($config, [$name => require $file]);
}
}
return $config;
}
}Ekspresi [$name => require $file] mungkin terlihat padat: require mengembalikan nilai dari statement return di dalam berkas, lalu nilai tersebut dibungkus array dengan nama berkas sebagai key. Karena configMerge dipanggil berulang, direktori kedua bisa menimpa direktori pertama — mekanisme yang sama dengan lapisan defaults:
<?php
declare(strict_types=1);
require __DIR__ . '/config.php';
// Siapkan direktori konfigurasi demo
$configDir = sys_get_temp_dir() . '/php-course-config';
@mkdir($configDir);
file_put_contents("$configDir/app.php", <<<'PHP'
<?php
declare(strict_types=1);
return [
'debug' => false,
'url' => 'https://toko.example.id',
];
PHP);
file_put_contents("$configDir/database.php", <<<'PHP'
<?php
declare(strict_types=1);
return [
'host' => 'db.internal',
'name' => 'toko_prod',
];
PHP);
$loader = new ConfigLoader();
$config = $loader->load([$configDir]);
$cfg = new Config($config);
echo 'app.debug : ', var_export($cfg->get('app.debug'), true), "\n";
echo 'database.host : ', $cfg->get('database.host'), "\n";
echo 'database.name : ', $cfg->get('database.name'), "\n";Output yang Dihasilkan:
app.debug : false
database.host : db.internal
database.name : toko_prodDua berkas terpisah kini menjadi satu peta bertingkat: app.debug dari app.php, database.host dari database.php. Nama komponen otomatis menjadi segmen pertama path — konvensi yang membuat pembaca langsung tahu berkas mana yang harus dibuka untuk mengubah sebuah nilai.
Validasi Saat Boot #
Aplikasi dengan konfigurasi setengah terisi adalah bom waktu: berjalan normal sampai titik pertama yang membaca nilai kosong. Prinsip fail fast menyuruh kita memeriksa semua nilai wajib SEKALI saat aplikasi menyala, lalu menolak hidup dengan pesan yang jelas:
<?php
declare(strict_types=1);
class ConfigException extends RuntimeException
{
}
/**
* Memeriksa daftar path konfigurasi yang wajib terisi.
*
* @param array<string, mixed> $config
* @param list<string> $wajib
*/
function validateConfig(array $config, array $wajib): void
{
$cari = new Config($config);
$hilang = [];
foreach ($wajib as $path) {
if ($cari->get($path) === null) {
$hilang[] = $path;
}
}
if ($hilang !== []) {
throw new ConfigException(
'Konfigurasi wajib belum terisi: ' . implode(', ', $hilang)
);
}
}Fungsi ini memakai kembali dot access — daftar kebutuhan ditulis sebagai path db.host, bukan logika array manual. Pemakaianya di titik masuk aplikasi:
<?php
declare(strict_types=1);
require __DIR__ . '/config.php';
$configLengkap = [
'db' => [
'host' => 'db.internal',
'name' => 'toko_prod',
'password' => 'YOUR_DATABASE_PASSWORD',
],
];
$configKosong = [
'db' => ['host' => 'db.internal'],
];
validateConfig($configLengkap, ['db.host', 'db.name', 'db.password']);
echo "Boot 1: konfigurasi lengkap, aplikasi siap jalan\n";
try {
validateConfig($configKosong, ['db.host', 'db.name', 'db.password']);
} catch (ConfigException $e) {
echo 'Boot 2: ', $e->getMessage(), "\n";
}Output yang Dihasilkan:
Boot 1: konfigurasi lengkap, aplikasi siap jalan
Boot 2: Konfigurasi wajib belum terisi: db.name, db.passwordPerhatikan pesan pada Boot 2: bukan undefined index di baris 214 saat query pertama dijalankan, melainkan daftar lengkap db.name, db.password saat aplikasi baru dinyalakan. Operator deployment langsung tahu apa yang harus diisi sebelum traffic diterima. Pesan galat yang muncul di tempat dan waktu yang tepat menghemat jam-jam penelusuran.
Cache Konfigurasi untuk Produksi #
Tiga lapis merge, glob direktori, dan pembacaan environment adalah biaya yang dibayar SETIAP request. Di development tak terasa; di produksi dengan ribuan request per detik, itu pemborosan murni — hasil akhirnya kan selalu sama sampai deploy berikutnya. Solusinya: jalankan seluruh alur sekali saat deploy, simpan hasilnya sebagai satu berkas PHP biasa, lalu di runtime cukup require berkas tersebut:
<?php
declare(strict_types=1);
require __DIR__ . '/config.php';
$config = [
'db' => ['host' => 'db.internal', 'port' => 3306],
'app' => ['debug' => false],
];
// Saat deploy: "kompilasi" konfigurasi menjadi satu berkas PHP
$cacheFile = sys_get_temp_dir() . '/php-course-config-cache.php';
file_put_contents($cacheFile, '<?php return ' . var_export($config, true) . ';');
// Saat runtime: satu require — tanpa glob, tanpa merge, tanpa baca env
$cached = require $cacheFile;
$cfg = new Config($cached);
echo 'db.host : ', $cfg->get('db.host'), "\n";
echo 'app.debug: ', var_export($cfg->get('app.debug'), true), "\n";
unlink($cacheFile);Output yang Dihasilkan:
db.host : db.internal
app.debug: falseTriknya ada pada var_export(): fungsi ini mengubah array menjadi kode PHP yang valid, sehingga berkas hasilnya berbentuk <?php return array(...); — persis format yang bisa dimuat kembali oleh require. Satu opcache hit untuk satu berkas, tanpa kerja runtime sama sekali. Inilah yang dimaksud config:cache di Laravel atau dumped container di Symfony: bukan sihir, hanya serialize-ke-kode-PHP.
Ringkasan #
Sistem konfigurasi yang sehat memisahkan kode dari nilai: deep merge menyusun tiga lapis prioritas (defaults, berkas proyek, environment), dot access memberi cara baca yang aman dengan nilai bawaan, dan normalisasi menjaga tipe data tetap benar. Konfigurasi dipecah per komponen dengan nama berkas sebagai grup, divalidasi seluruhnya saat boot agar kegagalan muncul cepat dan jelas, lalu dikompilasi menjadi satu berkas cache untuk produksi. Framework mini kita kini lengkap dari router hingga konfigurasi — dan kegagalan yang selama ini kita biarkan berhenti di catch sederhana perlu naik kelas menjadi lapisan penanganan tersendiri, topik chapter Exception Handling Layer
.
Referensi #
- php.net — array_replace_recursive()
- php.net — var_export()
- php.net — Variable environment $_ENV
- Laravel Docs — Configuration
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
config.php
Unduh berkas<?php
declare(strict_types=1);
/**
* Sistem konfigurasi mini — inti chapter Configuration System.
*
* Berisi configMerge(), Config (dot access), overrideDariEnv(),
* validateConfig(), dan ConfigLoader; setiap contoh chapter memuat
* berkas ini dengan require.
*/
/**
* Menggabungkan dua lapisan konfigurasi secara rekursif.
* Key pada $lapisanAtas menimpa key yang sama pada $basis.
*
* @param array<string, mixed> $basis
* @param array<string, mixed> $lapisanAtas
* @return array<string, mixed>
*/
function configMerge(array $basis, array $lapisanAtas): array
{
foreach ($lapisanAtas as $kunci => $nilai) {
if (is_array($nilai) && isset($basis[$kunci]) && is_array($basis[$kunci])) {
$basis[$kunci] = configMerge($basis[$kunci], $nilai);
continue;
}
$basis[$kunci] = $nilai;
}
return $basis;
}
final class Config
{
/** @param array<string, mixed> $values */
public function __construct(private readonly array $values) {}
/** Membaca nilai dengan path bertitik, mis. "db.host". */
public function get(string $path, mixed $default = null): mixed
{
$nilai = $this->values;
foreach (explode('.', $path) as $segmen) {
if (!is_array($nilai) || !array_key_exists($segmen, $nilai)) {
return $default;
}
$nilai = $nilai[$segmen];
}
return $nilai;
}
}
/** Mengubah nilai string environment menjadi tipe PHP yang tepat. */
function normalizeEnvValue(string $nilai): mixed
{
return match (strtolower($nilai)) {
'true' => true,
'false' => false,
'null' => null,
default => is_numeric($nilai) ? $nilai + 0 : $nilai,
};
}
/**
* Menimpa konfigurasi dengan nilai dari environment.
*
* @param array<string, mixed> $config
* @param array<string, string> $environment
* @param array<string, string> $peta variabel env => path konfigurasi
* @return array<string, mixed>
*/
function overrideDariEnv(array $config, array $environment, array $peta): array
{
foreach ($peta as $variabel => $path) {
if (!array_key_exists($variabel, $environment)) {
continue;
}
$segmen = explode('.', $path);
$referensi = &$config;
foreach ($segmen as $i => $bagian) {
if ($i === array_key_last($segmen)) {
$referensi[$bagian] = normalizeEnvValue($environment[$variabel]);
continue;
}
$referensi[$bagian] ??= [];
$referensi = &$referensi[$bagian];
}
unset($referensi);
}
return $config;
}
class ConfigException extends RuntimeException
{
}
/**
* Memeriksa daftar path konfigurasi yang wajib terisi.
*
* @param array<string, mixed> $config
* @param list<string> $wajib
*/
function validateConfig(array $config, array $wajib): void
{
$cari = new Config($config);
$hilang = [];
foreach ($wajib as $path) {
if ($cari->get($path) === null) {
$hilang[] = $path;
}
}
if ($hilang !== []) {
throw new ConfigException(
'Konfigurasi wajib belum terisi: ' . implode(', ', $hilang)
);
}
}
final class ConfigLoader
{
/**
* Memuat seluruh berkas *.php dalam daftar direktori; nama berkas
* menjadi key grup (app.php => "app"), isi return-nya menjadi nilai.
*
* @param list<string> $directories
* @return array<string, mixed>
*/
public function load(array $directories): array
{
$config = [];
foreach ($directories as $directory) {
$files = glob($directory . '/*.php') ?: [];
foreach ($files as $file) {
$name = basename($file, '.php');
$config = configMerge($config, [$name => require $file]);
}
}
return $config;
}
}