Setelah tiga proyek CLI sebelumnya, sekarang kita menyelam ke medium yang membuat PHP terkenal: halaman web yang diminta lewat HTTP. Pada Bagian 1 ini kita membangun kerangka blog tanpa framework — tidak ada Laravel, tidak ada Symfony, tidak satu pun dependensi Composer. Yang kita rakit adalah tiga fondasi yang nanti dipakai semua bagian berikutnya: front controller sebagai satu pintu masuk seluruh request, tabel rute yang memetakan path ke handler, dan layout HTML yang membungkus konten setiap halaman. Artikelnya masih ditulis tetap di dalam template; justru itu yang membuat kita bisa fokus pada struktur sebelum data datang pada Bagian 2 dan Bagian 3.

Arsitektur Proyek #

Blog ini mengikuti pola yang dipakai hampir semua framework modern: hanya ada satu berkas PHP yang boleh diakses web, sisanya di-import dari dalamnya. Struktur direktori pada tahap ini:

text
website-blog/
├── public/
│   └── index.php      front controller — satu pintu masuk semua request
└── templates/
    ├── layout.php     kerangka HTML: <head>, header, footer
    └── home.php       konten beranda: daftar artikel

Direktori public/ sengaja dipisahkan supaya kelak bisa dijadikan document root — berkas di luar public/ (nanti: src/ dan database/) tidak pernah bisa diminta langsung lewat HTTP. Alur satu request dari masuk sampai keluar terdiri dari empat langkah pendek:

Browser mengirim request ke public/index.php, front controller mengambil path dari $_SERVER['REQUEST_URI'], mencocokkannya dengan tabel rute, lalu handler yang cocok merender template dan membungkusnya dengan layout. Path yang tidak dikenal jatuh ke handler 404 — tetap berupa halaman HTML yang rapi, bukan error mentah server.

Front Controller dan Penjaga CLI #

Berikut isi lengkap public/index.php pada tahap ini:

php
<?php

declare(strict_types=1);

/**
 * public/index.php — front controller website blog.
 *
 * Ini satu-satunya berkas PHP yang diakses langsung oleh web server.
 * Setiap request HTTP masuk ke berkas ini, dicocokkan dengan tabel rute,
 * lalu template dirender dan dikirim sebagai respons.
 */

// ---------------------------------------------------------------------------
// Penjaga CLI
// ---------------------------------------------------------------------------
// Dijalankan langsung dari terminal tanpa request apa pun (misalnya
// `php index.php` oleh skrip verifikasi) harus berakhir rapi: tampilkan
// petunjuk pemakaian lalu keluar dengan status sukses (0), bukan error.
if (PHP_SAPI === 'cli' && !isset($_SERVER['REQUEST_URI'])) {
    echo "Blog PHP — front controller\n\n";
    echo "Berkas ini menangani request HTTP, bukan dijalankan langsung.\n";
    echo "Menyalakan server pengembangan dari root proyek:\n";
    echo "    php -S 127.0.0.1:8093 public/index.php\n\n";
    echo "Setelah server menyala, buka http://127.0.0.1:8093/ di browser.\n";
    exit(0);
}

// ---------------------------------------------------------------------------
// Helper bersama
// ---------------------------------------------------------------------------

if (!function_exists('e')) {
    /** Escape teks untuk HTML — dipakai di semua template. */
    function e(?string $nilai): string
    {
        return htmlspecialchars($nilai ?? '', ENT_QUOTES, 'UTF-8');
    }
}

/**
 * Render satu file template menjadi string.
 *
 * Variabel pada $data diekstrak menjadi variabel lokal di dalam template,
 * lalu output yang dihasilkan template ditangkap dengan output buffering
 * (ob_start) dan dikembalikan sebagai string.
 */
function render(string $template, array $data = []): string
{
    extract($data, EXTR_SKIP);
    ob_start();
    require __DIR__ . '/../templates/' . $template;
    return (string) ob_get_clean();
}

// ---------------------------------------------------------------------------
// Handler halaman — masing-masing mengembalikan [status, html]
// ---------------------------------------------------------------------------

function halamanBeranda(): array
{
    $konten = render('home.php');
    return [200, render('layout.php', ['judul' => 'Beranda', 'konten' => $konten])];
}

function halamanTentang(): array
{
    $konten = <<<'HTML'
    <article class="halaman">
        <h2>Tentang Blog Ini</h2>
        <p>Blog latihan yang dibangun dengan PHP murni — tanpa framework,
        tanpa dependensi eksternal. Seluruh kode penyusunnya bisa Anda baca
        dari awal sampai akhir.</p>
    </article>
    HTML;
    return [200, render('layout.php', ['judul' => 'Tentang', 'konten' => $konten])];
}

function halamanTidakDitemukan(string $path): array
{
    $konten = '<article class="halaman">'
        . '<h2>404 — Halaman Tidak Ditemukan</h2>'
        . '<p>Rute <code>' . e($path) . '</code> tidak dikenali oleh blog ini.</p>'
        . '<p>Kembali ke <a href="/">beranda</a>?</p>'
        . '</article>';
    return [404, render('layout.php', ['judul' => '404', 'konten' => $konten])];
}

// ---------------------------------------------------------------------------
// dispatch — inti routing: request mana yang menghasilkan respons apa
// ---------------------------------------------------------------------------

/**
 * Cocokkan path dari request dengan tabel rute.
 *
 * Murni tanpa efek samping: tidak mencetak apa pun dan tidak mengirim
 * header, hanya MENGEMBALIKAN pasangan [status, html]. Titik masuk di
 * bawah yang menerapkan status dan mencetak html — pemisahan inilah yang
 * membuat dispatch bisa diuji berulang kali dari skrip CLI.
 *
 * @param array<string,mixed> $server isi $_SERVER (asli atau tiruan)
 * @return array{0: int, 1: string} pasangan [status HTTP, badan respons]
 */
function dispatch(array $server): array
{
    // parse_url dapat mengembalikan false pada URI yang ganjil (mis. "//"),
    // jadi kita pakai ?: — bukan ?? — agar false pun jatuh ke '/'.
    $path = (string) (parse_url((string) ($server['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?: '/');
    $path = rtrim($path, '/') ?: '/'; // '/tentang/' juga diterima

    $rute = [
        '/' => 'halamanBeranda',
        '/tentang' => 'halamanTentang',
    ];

    if (isset($rute[$path])) {
        $handler = $rute[$path];
        return $handler();
    }

    return halamanTidakDitemukan($path);
}

// ---------------------------------------------------------------------------
// Titik masuk — satu-satunya tempat dengan efek samping
// ---------------------------------------------------------------------------

[$status, $html] = dispatch($_SERVER);
http_response_code($status);
echo $html;

Mari kita bedah berkas ini per blok, karena keenam bagian berikutnya terus menumpuk di atasnya:

  1. Penjaga CLI: konstanta PHP_SAPI bernilai "cli" ketika berkas dijalankan lewat terminal, dan terminal tidak mengisi $_SERVER['REQUEST_URI']. Dua syarat bersama-sama menjadi tanda “ini bukan request web”, sehingga kita mencetak petunjuk ramah lalu exit(0) — bukan error menakutkan. Pemeriksaan ganda ini juga membuka pintu trik penting: skrip pengujian bisa mengisi $_SERVER['REQUEST_URI'] dengan nilai tiruan sebelum memuat berkas ini, dan penjaga CLI pun meloloskannya sebagai request palsu.
  2. Helper e(): pembungkus htmlspecialchars() yang mengubah <, >, ", ', dan & menjadi entitas HTML. Setiap nilai dinamis yang dicetak ke halaman wajib melewati helper ini — kebiasaan yang melindungi blog dari XSS sejak halaman pertama.
  3. Helper render(): intinya tiga baris. extract($data, EXTR_SKIP) mengubah kunci array menjadi variabel lokal (['judul' => ...] menjadi $judul), ob_start() menyalakan output buffering agar require template tidak langsung menulis ke respons, lalu ob_get_clean() menangkap seluruh output template sebagai string. Flag EXTR_SKIP mencegah extract menimpa variabel yang sudah ada — tanpa flag itu, array dari pemanggil bisa menyusupkan variabel tak terduga ke dalam template.
  4. Handler halaman: setiap handler murni membangun nilai — mengembalikan pasangan [status, html] tanpa menyentuh echo maupun header(). Berkat itu, handler bisa dipanggil dari skrip CLI untuk diperiksa hasilnya.
  5. dispatch(): mengambil path bersih dari URI (membuang query string lewat parse_url), menormalkan garis miring berlebih dengan rtrim, lalu mencocokkannya dengan tabel rute berupa array asosiatif sederhana. Router framework pada dasarnya adalah tabel seperti ini ditambah dukungan parameter — yang kita tambahkan pada Bagian 2.
  6. Titik masuk: tiga baris terakhir adalah satu-satunya tempat dengan efek samping nyata — menerapkan status lewat http_response_code() dan mencetak HTML. Semua logika di atasnya tetap murni dan bisa diuji.

Penjaga CLI bisa dilihat langsung hasilnya tanpa web server:

php
<?php

// Menjalankan front controller dari terminal: penjaga CLI mencetak
// petunjuk penggunaan lalu keluar dengan exit code 0 — tanpa error.
require __DIR__ . '/public/index.php';

Output yang Dihasilkan:

text
Blog PHP — front controller

Berkas ini menangani request HTTP, bukan dijalankan langsung.
Menyalakan server pengembangan dari root proyek:
    php -S 127.0.0.1:8093 public/index.php

Setelah server menyala, buka http://127.0.0.1:8093/ di browser.

Routing Tanpa Database Rute #

Tabel rute sebesar dua baris memang terlihat sederhana, tetapi bentuknya sudah benar: routing adalah pencarian nilai berdasarkan kunci, dan isset($rute[$path]) adalah pencarian O(1) paling cepat di PHP. Karena dispatch() tidak menyentuh superglobal asli, kita bisa menguji seluruh keputusan routing dari CLI dengan array $_SERVER tiruan. Muat front controller sekali (sambil menyamar sebagai request web supaya penjaga CLI meloloskannya), lalu panggil dispatch() berulang kali:

php
<?php

// Muat front controller sambil menyamar sebagai request GET /.
$_SERVER['REQUEST_URI'] = '/';

ob_start();
require __DIR__ . '/public/index.php';
ob_end_clean(); // hasil render ditampilkan pada cuplikan berikutnya

// dispatch() kini terdefinisi — uji beberapa path dengan array $_SERVER tiruan.
foreach (['/', '/?halaman=2', '/tentang', '/tentang/', '/halo'] as $uri) {
    [$status, $html] = dispatch(['REQUEST_URI' => $uri]);
    printf("%-16s -> %d (%d byte html)\n", $uri, $status, strlen($html));
}

Output yang Dihasilkan:

text
/                -> 200 (1744 byte html)
/?halaman=2      -> 200 (1744 byte html)
/tentang         -> 200 (825 byte html)
/tentang/        -> 200 (825 byte html)
/halo            -> 404 (756 byte html)

Tiga perilaku routing terbaca langsung dari tabel di atas. Pertama, query string dibuang — /?halaman=2 dan / menghasilkan halaman yang sama karena parse_url(..., PHP_URL_PATH) hanya mengambil bagian path. Kedua, garis miring penutup diterima — /tentang/ dinormalkan menjadi /tentang oleh rtrim. Ketiga, path yang tidak dikenal seperti /halo jatuh ke handler 404 dengan status 404 dan halaman HTML yang tetap utuh.

Layout dan Template dengan Sintaks Alternatif #

Sekarang lapisan tampilan. templates/layout.php adalah kerangka yang dipakai bersama seluruh halaman — ia menerima judul dan fragmen konten, lalu membingungkai keduanya dengan header navigasi dan footer:

php
<?php

/**
 * templates/layout.php — kerangka HTML yang membingkai setiap halaman.
 *
 * Template ini tidak menghasilkan konten sendiri; ia menerima dua variabel:
 * $judul (untuk elemen <title>) dan $konten (fragmen HTML dari template
 * halaman). Keduanya punya nilai bawaan supaya berkas ini tetap aman
 * di-include tanpa data — misalnya saat diperiksa langsung lewat CLI.
 */

if (!function_exists('e')) {
    /** Escape teks untuk HTML — dipakai di semua template. */
    function e(?string $nilai): string
    {
        return htmlspecialchars($nilai ?? '', ENT_QUOTES, 'UTF-8');
    }
}

$judul ??= 'Blog PHP';
$konten ??= '';
?>
<!DOCTYPE html>
<html lang="id">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title><?= e($judul) ?> — Blog PHP</title>
</head>
<body>
    <header class="site-header">
        <strong><a href="/">Blog PHP</a></strong>
        <nav>
            <a href="/">Beranda</a> |
            <a href="/tentang">Tentang</a>
        </nav>
    </header>
    <main class="site-main">
<?= $konten ?>
    </main>
    <footer class="site-footer">
        <p>Dibangun dengan PHP murni — proyek kursus "Website Blog".</p>
    </footer>
</body>
</html>

Dua keputusan desain di sini penting. Pertama, $judul ??= 'Blog PHP' dan $konten ??= '' memberi nilai bawaan ketika template di-include tanpa data — itulah sebabnya berkas ini aman dijalankan langsung tanpa peringatan “Undefined variable”. Kedua, $konten sengaja dicetak mentah tanpa e(): variabel tersebut bukan teks dari pengguna, melainkan fragmen HTML hasil render template lain; meng-escape-nya akan menghancurkan struktur halaman. Data asli di dalam template konten-lah yang di-escape satu per satu.

Adapun templates/home.php menghasilkan fragmen daftar artikel untuk beranda. Data artikel masih ditulis tetap di dalam berkas — pada Bagian 2 ia pindah ke class PostRepository dan template cukup menerima variabel $posts:

php
<?php

/**
 * templates/home.php — halaman beranda: daftar artikel terbaru.
 *
 * Data artikel masih di-hardcode di sini. Pada Bagian 2 data ini pindah
 * ke class PostRepository, jadi template cukup menerima variabel $posts
 * dari pemanggilnya. Baris `$posts ??= [...]` memberi nilai bawaan agar
 * berkas ini tetap aman dijalankan langsung tanpa data dari luar.
 */

if (!function_exists('e')) {
    /** Escape teks untuk HTML — dipakai di semua template. */
    function e(?string $nilai): string
    {
        return htmlspecialchars($nilai ?? '', ENT_QUOTES, 'UTF-8');
    }
}

$posts ??= [
    [
        'slug' => 'kenapa-php-masih-relevan',
        'judul' => 'Kenapa PHP Masih Relevan di 2026',
        'ringkasan' => 'Bahasa yang menggerakkan sebagian besar web ternyata masih terus berkembang: rilis besar tiap tahun, ekosistem yang matang, dan kinerja yang jauh melampaui reputasi lamanya.',
        'dibuat_pada' => '2026-01-03',
    ],
    [
        'slug' => 'membangun-router-sederhana',
        'judul' => 'Membangun Router Sederhana Tanpa Framework',
        'ringkasan' => 'Router pada framework favorit Anda sebenarnya hanyalah array asosiatif dan pencocokan string. Kita buktikan dengan membangunnya sendiri dalam satu berkas.',
        'dibuat_pada' => '2026-01-10',
    ],
    [
        'slug' => 'tips-aman-menampilkan-data-pengguna',
        'judul' => 'Tips Aman Menampilkan Data Pengguna',
        'ringkasan' => 'Setiap nilai yang berasal dari pengguna harus di-escape sebelum ditampilkan. Satu helper kecil bernama e() menyelamatkan seluruh template Anda.',
        'dibuat_pada' => '2026-01-17',
    ],
];
?>
<section class="post-list">
    <h2>Artikel Terbaru</h2>
<?php foreach ($posts as $post): ?>
    <article class="post">
        <h3><a href="/post/<?= e($post['slug']) ?>"><?= e($post['judul']) ?></a></h3>
        <p><?= e($post['ringkasan']) ?></p>
        <small>Diterbitkan <?= e($post['dibuat_pada']) ?></small>
    </article>
<?php endforeach; ?>
</section>

Perhatikan gaya penulisan templatenya: foreach (...): … endforeach; — ini disebut sintaks alternatif dan wajib tradisi di dunia template. Dibanding kurung kurawal, kata kunci penutup eksplisit membuat blok HTML bertingkat tetap terbaca ketika editor melipat tag. Tag <?= ... ?> adalah bentuk pendek <?php echo ... ?> yang aman dipakai karena selalu aktif di PHP modern. Setiap nilai dinamis — slug, judul, ringkasan, tanggal — melewati e() sebelum dicetak; hanya struktur HTML statis yang ditulis mentah.

Komposisi layout dan konten bisa dilihat utuh hasilnya. Cuplikan berikut menyamar sebagai request GET /, menangkap respons front controller dengan output buffering, lalu mencetaknya:

php
<?php

// Menyamar sebagai request web GET /, lalu menangkap seluruh HTML-nya.
$_SERVER['REQUEST_URI'] = '/';

ob_start();
require __DIR__ . '/public/index.php';
echo (string) ob_get_clean();

Output yang Dihasilkan:

text
<!DOCTYPE html>
<html lang="id">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Beranda — Blog PHP</title>
</head>
<body>
    <header class="site-header">
        <strong><a href="/">Blog PHP</a></strong>
        <nav>
            <a href="/">Beranda</a> |
            <a href="/tentang">Tentang</a>
        </nav>
    </header>
    <main class="site-main">
<section class="post-list">
    <h2>Artikel Terbaru</h2>
    <article class="post">
        <h3><a href="/post/kenapa-php-masih-relevan">Kenapa PHP Masih Relevan di 2026</a></h3>
        <p>Bahasa yang menggerakkan sebagian besar web ternyata masih terus berkembang: rilis besar tiap tahun, ekosistem yang matang, dan kinerja yang jauh melampaui reputasi lamanya.</p>
        <small>Diterbitkan 2026-01-03</small>
    </article>
    <article class="post">
        <h3><a href="/post/membangun-router-sederhana">Membangun Router Sederhana Tanpa Framework</a></h3>
        <p>Router pada framework favorit Anda sebenarnya hanyalah array asosiatif dan pencocokan string. Kita buktikan dengan membangunnya sendiri dalam satu berkas.</p>
        <small>Diterbitkan 2026-01-10</small>
    </article>
    <article class="post">
        <h3><a href="/post/tips-aman-menampilkan-data-pengguna">Tips Aman Menampilkan Data Pengguna</a></h3>
        <p>Setiap nilai yang berasal dari pengguna harus di-escape sebelum ditampilkan. Satu helper kecil bernama e() menyelamatkan seluruh template Anda.</p>
        <small>Diterbitkan 2026-01-17</small>
    </article>
</section>
    </main>
    <footer class="site-footer">
        <p>Dibangun dengan PHP murni — proyek kursus "Website Blog".</p>
    </footer>
</body>
</html>

Baca ulang output di atas dari atas ke bawah: kerangka dari layout.php (doctype, head, header navigasi, footer) menyelimuti fragmen dari home.php (elemen <section> beserta tiga <article>). Inilah seluruh sihir komposisinya — render('home.php') menghasilkan string, string itu dikirim sebagai $konten ke render('layout.php'), dan hasil akhirnya menjadi body respons.

Menguji dengan Web Server Bawaan #

Pengujian sungguhan memakai web server bawaan PHP. Karena seluruh request harus melewati front controller, kita memakai bentuk perintah yang menjadikan public/index.php sebagai router — bukan -t public yang akan melayani berkas statis secara terpisah:

bash
# terminal 1 — dari direktori root proyek
$ php -S 127.0.0.1:8093 public/index.php

Server berjalan di foreground dan mencatat setiap request yang masuk. Dari terminal kedua, kita meminta halaman beranda beserta header-nya:

bash
# terminal 2
$ curl -sS -i http://127.0.0.1:8093/ | head -n 22
text
HTTP/1.1 200 OK
Host: 127.0.0.1:8093
Date: Tue, 13 Oct 2026 21:36:57 GMT
Connection: close
X-Powered-By: PHP/8.4.23
Content-type: text/html; charset=UTF-8

<!DOCTYPE html>
<html lang="id">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Beranda — Blog PHP</title>
</head>
<body>
    <header class="site-header">
        <strong><a href="/">Blog PHP</a></strong>
        <nav>
            <a href="/">Beranda</a> |
            <a href="/tentang">Tentang</a>

Status 200, Content-type: text/html, dan body HTML yang persis sama dengan hasil simulasi CLI — kali ini lewat jalur HTTP sungguhan; baris Date akan menyesuaikan jam permintaan Anda. Giliran halaman yang tidak dikenal:

bash
$ curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8093/halusinasi
text
404
bash
$ curl -sS http://127.0.0.1:8093/halusinasi | grep '<code>'
text
<p>Rute <code>/halusinasi</code> tidak dikenali oleh blog ini.</p>

Handler 404 mengembalikan status yang benar dan menyisipkan path yang diminta ke halaman — sudah di-escape, karena path adalah data yang datang dari luar. Matikan server dengan Ctrl+C pada terminal pertama setelah selesai mencoba.

Apa yang Berubah #

Dibandingkan dengan sebelum proyek dimulai, tahap ini menambahkan tiga berkas yang menjadi tulang punggung seluruh proyek:

  • public/index.php — front controller dengan penjaga CLI, helper e() dan render(), tiga handler halaman, serta dispatch() berbasis tabel rute.
  • templates/layout.php — kerangka HTML dengan nilai bawaan defensif untuk $judul dan $konten.
  • templates/home.php — fragmen beranda dengan data artikel tetap dan sintaks alternatif foreach/endforeach.

Belum ada halaman detail artikel, belum ada sumber data selain hardcode, dan tabel rute belum mengenal parameter. Bagian 2 memperkenalkan class pertama proyek ini — PostRepository — beserta rute parameter /post/{slug}.

Cara Menjalankan #

Dari direktori bagian-1-routing-dan-layout/ (folder bundle halaman ini), tiga lapisan pemeriksaan tersedia:

bash
# 1. Penjaga CLI — petunjuk pemakaian, exit code 0
php public/index.php

# 2. Web server bawaan + curl (terminal terpisah)
php -S 127.0.0.1:8093 public/index.php
curl -sS -i http://127.0.0.1:8093/
curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8093/halusinasi

# 3. Pemeriksaan sintaks seluruh berkas
php -l public/index.php && php -l templates/layout.php && php -l templates/home.php

Tiga berkas pendukung halaman ini — public/index.php, templates/layout.php, dan templates/home.php — tersedia di bagian bawah halaman dan dapat diunduh langsung. Untuk pendalaman, baca dokumentasi resmi tentang superglobal $_SERVER , parse_url() , output buffering pada ob_start() , serta http_response_code() . Dengan kerangka yang berdiri, kita lanjut ke Bagian 2 untuk memberi blog ini halaman detail artikel dan sumber data yang layak.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

templates/home.php

Unduh berkas
php
<?php

/**
 * templates/home.php — halaman beranda: daftar artikel terbaru.
 *
 * Data artikel masih di-hardcode di sini. Pada Bagian 2 data ini pindah
 * ke class PostRepository, jadi template cukup menerima variabel $posts
 * dari pemanggilnya. Baris `$posts ??= [...]` memberi nilai bawaan agar
 * berkas ini tetap aman dijalankan langsung tanpa data dari luar.
 */

if (!function_exists('e')) {
    /** Escape teks untuk HTML — dipakai di semua template. */
    function e(?string $nilai): string
    {
        return htmlspecialchars($nilai ?? '', ENT_QUOTES, 'UTF-8');
    }
}

$posts ??= [
    [
        'slug' => 'kenapa-php-masih-relevan',
        'judul' => 'Kenapa PHP Masih Relevan di 2026',
        'ringkasan' => 'Bahasa yang menggerakkan sebagian besar web ternyata masih terus berkembang: rilis besar tiap tahun, ekosistem yang matang, dan kinerja yang jauh melampaui reputasi lamanya.',
        'dibuat_pada' => '2026-01-03',
    ],
    [
        'slug' => 'membangun-router-sederhana',
        'judul' => 'Membangun Router Sederhana Tanpa Framework',
        'ringkasan' => 'Router pada framework favorit Anda sebenarnya hanyalah array asosiatif dan pencocokan string. Kita buktikan dengan membangunnya sendiri dalam satu berkas.',
        'dibuat_pada' => '2026-01-10',
    ],
    [
        'slug' => 'tips-aman-menampilkan-data-pengguna',
        'judul' => 'Tips Aman Menampilkan Data Pengguna',
        'ringkasan' => 'Setiap nilai yang berasal dari pengguna harus di-escape sebelum ditampilkan. Satu helper kecil bernama e() menyelamatkan seluruh template Anda.',
        'dibuat_pada' => '2026-01-17',
    ],
];
?>
<section class="post-list">
    <h2>Artikel Terbaru</h2>
<?php foreach ($posts as $post): ?>
    <article class="post">
        <h3><a href="/post/<?= e($post['slug']) ?>"><?= e($post['judul']) ?></a></h3>
        <p><?= e($post['ringkasan']) ?></p>
        <small>Diterbitkan <?= e($post['dibuat_pada']) ?></small>
    </article>
<?php endforeach; ?>
</section>

templates/layout.php

Unduh berkas
php
<?php

/**
 * templates/layout.php — kerangka HTML yang membingkai setiap halaman.
 *
 * Template ini tidak menghasilkan konten sendiri; ia menerima dua variabel:
 * $judul (untuk elemen <title>) dan $konten (fragmen HTML dari template
 * halaman). Keduanya punya nilai bawaan supaya berkas ini tetap aman
 * di-include tanpa data — misalnya saat diperiksa langsung lewat CLI.
 */

if (!function_exists('e')) {
    /** Escape teks untuk HTML — dipakai di semua template. */
    function e(?string $nilai): string
    {
        return htmlspecialchars($nilai ?? '', ENT_QUOTES, 'UTF-8');
    }
}

$judul ??= 'Blog PHP';
$konten ??= '';
?>
<!DOCTYPE html>
<html lang="id">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title><?= e($judul) ?> — Blog PHP</title>
</head>
<body>
    <header class="site-header">
        <strong><a href="/">Blog PHP</a></strong>
        <nav>
            <a href="/">Beranda</a> |
            <a href="/tentang">Tentang</a>
        </nav>
    </header>
    <main class="site-main">
<?= $konten ?>
    </main>
    <footer class="site-footer">
        <p>Dibangun dengan PHP murni — proyek kursus "Website Blog".</p>
    </footer>
</body>
</html>