Kodeloka
  1. Kodeloka
  2. Kelas PHP
  3. Materi

5 · Arsitektur Aplikasi · Modul 21 dari 25

Struktur Direktori Proyek Siap Produksi, Konfigurasi Lingkungan, dan Logging Terstruktur (PSR-3)

Mengorganisasikan arsitektur direktori aplikasi PHP yang memisahkan public/ dari kode sumber internal, mengelola konfigurasi via variabel lingkungan (getenv), serta menerapkan pencatatan log terstruktur (structured JSON logging) sesuai standar PSR-3.

1. Struktur Direktori Standar Industri: Memisahkan public/ dan src/

Mari kita telaah anatomi lengkap proyek aplikasi PHP modern yang siap di-deploy ke server Linux maupun kontainer Docker:

kodeloka-backend/
├── .env.example               <-- Contoh variabel lingkungan (Aman di-commit ke Git)
├── .gitignore                 <-- Mengabaikan /vendor/, /.env, dan /storage/*.log
├── composer.json              <-- Definisi dependensi & autoloading PSR-4
├── composer.lock              <-- Kunci versi eksak dependensi (WAJIB di-commit ke Git!)
├── phpunit.xml                <-- Konfigurasi pengujian otomatis
├── bin/
│   └── konsol                 <-- Skrip CLI untuk migrasi database atau tugas terjadwal (cron)
├── config/
│   └── aplikasi.php           <-- Membaca getenv() dan memvalidasi tipe konfigurasi
├── public/                    <-- DOCUMENT ROOT NGINX (Satu-satunya folder yang terekspos ke web!)
│   ├── index.php              <-- Front Controller (Pintu masuk tunggal HTTP)
│   └── assets/                <-- Berkas statis CSS, JS, dan gambar
├── src/                       <-- Kode sumber PSR-4 (Namespace: Kodeloka\)
│   ├── Controller/            <-- Menerima Request, memanggil Service, mengembalikan Response
│   ├── Domain/                <-- Entitas, Value Object Readonly, Enum, dan Domain Exception
│   ├── Service/               <-- Logika bisnis aplikasi
│   ├── Repository/            <-- Kueri database PDO Prepared Statements
│   └── Keamanan/              <-- CSRF, Sesi, dan Middleware
├── storage/                   <-- Penyimpanan lokal di luar web root (log, cache, unggahan privat)
├── templates/                 <-- Berkas tampilan HTML (Views)
└── tests/                     <-- Pengujian otomatis PHPUnit (Unit & Integration Tests)

Mengapa Pemisahan Tanggung Jawab (Separation of Concerns) di src/ Penting?

  • **Controller Tetap Tipis (Thin Controllers): Controller hanya bertugas membaca dan memvalidasi parameter HTTP, memanggil metode di Service, lalu memilih template atau mengembalikan JSON. Controller tidak boleh** berisi kueri SQL SELECT/INSERT mentah!
  • Domain & Service Bebas dari HTTP: Kelas di dalam Domain/ dan Service/ tidak boleh mengakses $_POST atau $_SESSION secara langsung; mereka menerima argumen bertipe jelas (seperti string $surel atau objek DTO) sehingga dapat dipanggil baik dari web (public/index.php), dari perintah terminal (bin/konsol), maupun dari pengujian unit (tests/)!

2. Membaca dan Memvalidasi Konfigurasi dengan Objek Konfigurasi readonly

Daripada memanggil getenv('DB_DSN') secara tersebar di puluhan berkas (yang berisiko salah ketik nama variabel), bungkus seluruh konfigurasi lingkungan ke dalam sebuah readonly class KonfigurasiAplikasi yang divalidasi satu kali saat aplikasi menyala (fail-fast on startup):

<?php

declare(strict_types=1);

namespace Kodeloka\Konfigurasi;

use RuntimeException;

readonly class KonfigurasiAplikasi
{
    public function __construct(
        public string $namaAplikasi,
        public string $lingkungan,
        public bool $modeDebug,
        public string $dbDsn,
        public ?string $dbUser,
        public ?string $dbPass,
    ) {}

    public static function dariEnvironment(): self
    {
        $dsn = getenv('DB_DSN');
        if (!is_string($dsn) || trim($dsn) === '') {
            throw new RuntimeException('Variabel lingkungan wajib DB_DSN belum diatur.');
        }

        $lingkungan = getenv('APP_ENV') ?: 'production';

        return new self(
            namaAplikasi: getenv('APP_NAME') ?: 'Kodeloka',
            lingkungan: $lingkungan,
            // Pastikan mode debug TIDAK PERNAH aktif secara tak sengaja di production!
            modeDebug: $lingkungan !== 'production'
                && filter_var(getenv('APP_DEBUG') ?: 'false', FILTER_VALIDATE_BOOL),
            dbDsn: $dsn,
            dbUser: getenv('DB_USER') ?: null,
            dbPass: getenv('DB_PASS') ?: null,
        );
    }
}

3. Pencatatan Log Terstruktur (Structured Logging) Sesuai Standar PSR-3

Ketika terjadi masalah di server produksi pada pukul 02.00 dini hari, log adalah mata dan telinga Anda. Jangan menulis log sebagai kalimat bebas yang sulit dicari mesin seperti "User 42 gagal bayar pesanan 99 karena saldo kurang".

Menurut standar PSR-3 (Psr\Log\LoggerInterface) (yang diimplementasikan oleh pustaka populer Monolog), setiap catatan log terdiri dari tingkat keparahan, pesan, dan array konteks. PSR-3 tidak mengatur format keluaran; format JSON pada poin 3 dan kelas PencatatLogJson di bawah adalah praktik tambahan yang disederhanakan (kelas tersebut tidak mengimplementasikan Psr\Log\LoggerInterface):

  1. **Tingkat Keparahan (Log Level — RFC 5424):** debug, info, notice, warning, error, critical, alert, emergency.
  2. Pesan Statis yang Jelas disertai Array Konteks Terstruktur ($context)!
  3. **Format JSON Satu Baris (JSON Lines) ke stderr atau Berkas Log:** Memudahkan sistem observabilitas (seperti Grafana Loki, OpenTelemetry, atau Elasticsearch) memfilter berdasarkan context.user_id atau context.order_id!
<?php

declare(strict_types=1);

namespace Kodeloka\Observabilitas;

final class PencatatLogJson
{
    public function __construct(
        private readonly string $saluran = 'kodeloka-app',
        private readonly string $targetAliran = 'php://stderr',
    ) {}

    public function info(string $pesan, array $konteks = []): void
    {
        $this->tulis('INFO', $pesan, $konteks);
    }

    public function warning(string $pesan, array $konteks = []): void
    {
        $this->tulis('WARNING', $pesan, $konteks);
    }

    public function error(string $pesan, array $konteks = []): void
    {
        $this->tulis('ERROR', $pesan, $konteks);
    }

    private function tulis(string $tingkat, string $pesan, array $konteks): void
    {
        $rekaman = [
            'timestamp' => gmdate('Y-m-d\TH:i:s\Z'),
            'level'     => $tingkat,
            'channel'   => $this->saluran,
            'message'   => $pesan,
            'context'   => $this->saringDataSensitif($konteks),
        ];

        $barisJson = json_encode($rekaman, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n";
        file_put_contents($this->targetAliran, $barisJson, FILE_APPEND);
    }

    /**
     * WAJIB: Jangan pernah mencatat kata sandi, token sesi, atau kunci API ke dalam berkas log!
     */
    private function saringDataSensitif(array $konteks): array
    {
        $kunciTerlarang = ['password', 'sandi', 'kata_sandi', 'token', 'csrf_token', 'authorization'];
        foreach ($konteks as $kunci => $nilai) {
            if (in_array(strtolower((string) $kunci), $kunciTerlarang, true)) {
                $konteks[$kunci] = '[DISEMBUNYIKAN]';
            } elseif (is_array($nilai)) {
                $konteks[$kunci] = $this->saringDataSensitif($nilai);
            }
        }
        return $konteks;
    }
}

**🚫 Aturan Keamanan Log: Sensor Data Rahasia (Redaction) & #[SensitiveParameter]!**

  1. Jangan pernah mencatat kata sandi, nomor kartu kredit, atau token sesi ke dalam log!
  2. Sejak PHP 8.2+, pasang atribut bawaan #[\SensitiveParameter] pada parameter fungsi yang menerima kata sandi atau kunci rahasia: ``php public function hubungkanDb(string $user, #[\SensitiveParameter] string $password): void ` Dengan #[\SensitiveParameter], seandainya fungsi tersebut gagal dan mencetak *stack trace* ke log server, nilai $password di dalam *stack trace* otomatis diganti menjadi Object(SensitiveParameterValue)` sehingga kata sandi tidak pernah bocor ke berkas log!

Latihan Praktik: Mengamankan Parameter Sensitif dan Mencatat Log JSON

Buatlah berkas latihan-logging.php dan jalankan dengan php latihan-logging.php:

  1. Buat fungsi prosesAutentikasi(string $surel, #[\SensitiveParameter] string $sandi) yang sengaja melempar RuntimeException dan amati bahwa di dalam $e->getTraceAsString(), isi $sandi disembunyikan oleh #[\SensitiveParameter].
  2. Gunakan PencatatLogJson untuk mencatat kejadian login gagal lengkap dengan konteks ['surel' => $surel, 'sandi' => $sandi, 'ip' => '127.0.0.1'] dan pastikan nilai 'sandi' otomatis tersensor menjadi '[DISEMBUNYIKAN]'!