Controller kini mengembalikan Response, tetapi isi $body masih kita tulis sebagai string manual. Halaman web sungguhan berisi puluhan baris HTML — menyusunnya dengan operator titik di dalam PHP adalah penderitaan. Solusinya adalah template: berkas yang sebagian besar HTML, dengan celah-celah kecil berisi PHP untuk menempel data. Kabar baiknya, PHP sendiri sudah menjadi bahasa template — include dan output buffering adalah semua yang kita butuhkan. Pada chapter ini kita membangun mesin template mini lengkap dengan helper escaping, layout, dan section, lalu membandingkannya dengan Twig dan Blade.
Helper Escaping e() #
Aturan paling penting dalam merender HTML: data dari pengguna TIDAK BOLEH ditempel mentah. Nama produk <script>alert("xss")</script> yang dicetak tanpa pengolah akan dieksekusi browser sebagai script — inilah serangan Cross-Site Scripting. Penawarnya sederhana: setiap tempat data ditempel harus melewati fungsi escaping yang mengubah karakter berbahaya menjadi entitas HTML:
<?php
declare(strict_types=1);
function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
$inputPengguna = '<b>Budi</b> & "teman-temannya"';
echo 'Tanpa escaping : ', $inputPengguna, "\n";
echo 'Dengan e() : ', e($inputPengguna), "\n";Output yang Dihasilkan:
Tanpa escaping : <b>Budi</b> & "teman-temannya"
Dengan e() : <b>Budi</b> & "teman-temannya"Fungsi e() membungkus htmlspecialchars() dengan tiga keputusan penting: flag ENT_QUOTES meng-escape kedua jenis tanda kutip, dan encoding UTF-8 dipatok eksplisit agar hasilnya konsisten dengan HTML modern. Lihat output-nya: < berubah menjadi <, & menjadi &, dan tanda kutip menjadi " — browser akan menampilkannya sebagai teks biasa, bukan markup. Detail mekanisme serangan XSS dan strategi pertahanannya dibahas tuntas pada chapter Cross-Site Scripting
dan Output Escaping
.
Pola extract + include #
Cara paling dasar menggabungkan template dan data di PHP adalah sepasang fungsi: extract() mengubah key array menjadi variabel, lalu include menjalankan berkas template yang menggunakan variabel-variabel tersebut:
<?php
declare(strict_types=1);
/** Merender satu berkas template menjadi string. */
function render(string $templateFile, array $data): string
{
extract($data, EXTR_SKIP);
ob_start();
include $templateFile;
return (string) ob_get_clean();
}Mari kita bedah baris per baris:
extract($data, EXTR_SKIP);— setiap key array$datamenjadi variabel di dalam scoperender():['produk' => ...]menjadi$produk. FlagEXTR_SKIPsangat penting: jika template mengirim key yang bentrok dengan variabel yang sudah ada (misalnya$templateFile), key tersebut dilewati, bukan menimpa.ob_start();— menyalakan output buffering: semuaechodan teks HTML dari template tidak langsung terkirim, melainkan ditampung di buffer.include $templateFile;— berkas template dieksekusi di scope fungsi ini, sehingga variabel hasilextract()dan helpere()dapat dipakainya.return (string) ob_get_clean();— mengambil isi buffer sekaligus mematikannya, lalu mengembalikannya sebagai string.
Hasilnya adalah fungsi yang mengubah berkas template plus array data menjadi string HTML murni — tidak ada satu karakter pun yang bocor ke output. Kita lihat aksinya dengan berkas template sungguhan:
<?php
declare(strict_types=1);
require __DIR__ . '/view.php';
// Siapkan direktori template sementara untuk demo
$viewDir = sys_get_temp_dir() . '/php-course-views';
@mkdir($viewDir);
file_put_contents("$viewDir/produk.php", <<<'HTML'
<article>
<h2><?= e($produk['nama']) ?></h2>
<p>Harga: Rp <?= number_format($produk['harga'], 0, ',', '.') ?></p>
<?php if ($produk['stok'] > 0): ?>
<p>Status: masih tersedia</p>
<?php else: ?>
<p>Status: habis</p>
<?php endif ?>
</article>
HTML);
echo render("$viewDir/produk.php", [
'produk' => [
'nama' => 'Kopi Gayo <script>alert("xss")</script>',
'harga' => 50000,
'stok' => 12,
],
]);
Output yang Dihasilkan:
<article>
<h2>Kopi Gayo <script>alert("xss")</script></h2>
<p>Harga: Rp 50.000</p>
<p>Status: masih tersedia</p>
</article>Perhatikan tiga hal dari contoh di atas. Pertama, tag pembuka pendek <?= ... ?> adalah cara menempel nilai tanpa echo — <?= e($produk['nama']) ?> berarti “cetak nilai yang sudah di-escape”. Kedua, nama produk berisi tag <script> asli, tetapi output menampilkannya sebagai teks <script> — inilah e() bekerja. Ketiga, struktur if/else memakai alternative syntax (endif) yang jauh lebih rapi untuk template daripada kurung kurawal; topik ini sudah kita kenal pada chapter Alternative Syntax
.
Layout dengan Output Buffering #
Semua halaman satu aplikasi hampir selalu berbagi kerangka yang sama: <head>, navigasi, footer. Menyalin kerangka itu ke setiap template berarti mengubah navigasi di 30 berkas. Solusinya adalah layout: satu berkas kerangka yang menyediakan slot, dan halaman anak yang mengisi slot tersebut. Alat untuk menangkap isi slot tetap output buffering — kali ini dibungkus dalam class SectionStack:
<?php
declare(strict_types=1);
final class SectionStack
{
/** @var array<string, string> */
private array $sections = [];
/** @var list<string> */
private array $open = [];
public function start(string $name): void
{
$this->open[] = $name;
ob_start();
}
public function stop(): void
{
$name = (string) array_pop($this->open);
$this->sections[$name] = (string) ob_get_clean();
}
public function get(string $name, string $default = ''): string
{
return $this->sections[$name] ?? $default;
}
}Cara kerjanya: start('judul') membuka buffer baru dan mencatat nama section, stop() menutup buffer dan menyimpan isinya ke $sections['judul'], lalu get('judul') membacanya kapan pun dibutuhkan — biasanya oleh layout. Fungsi renderPage() merangkai seluruh alurnya: render halaman anak lebih dulu (mengisi section, output langsungnya dibuang), lalu render layout (membaca section):
<?php
declare(strict_types=1);
/** Merender halaman anak (mengisi section) lalu membungkusnya dengan layout. */
function renderPage(string $viewDir, string $template, array $data, string $layout = 'layout'): string
{
$sections = new SectionStack();
extract($data, EXTR_SKIP);
ob_start();
include $viewDir . '/' . $template . '.php';
ob_end_clean();
ob_start();
include $viewDir . '/' . $layout . '.php';
return (string) ob_get_clean();
}Sekarang kita buat satu layout dan satu halaman anak, lalu jalankan semuanya:
<?php
declare(strict_types=1);
require __DIR__ . '/view.php';
$viewDir = sys_get_temp_dir() . '/php-course-views';
@mkdir($viewDir);
// Layout — "kulit" halaman yang membaca section
file_put_contents("$viewDir/layout.php", <<<'HTML'
<!DOCTYPE html>
<html lang="id">
<head>
<title><?= e($sections->get('judul', 'Toko Budi')) ?></title>
</head>
<body>
<nav>Beranda | Produk | Kontak</nav>
<main>
<?= $sections->get('konten') ?>
</main>
<footer>Toko Budi © <?= e($tahun) ?></footer>
</body>
</html>
HTML);
// Halaman anak — mengisi section sebelum layout dirender
file_put_contents("$viewDir/kontak.php", <<<'HTML'
<?php $sections->start('judul') ?>Hubungi Kami<?php $sections->stop() ?>
<?php $sections->start('konten') ?>
<h1>Hubungi Kami</h1>
<p>Email: <strong><?= e($email) ?></strong></p>
<?php $sections->stop() ?>
HTML);
echo renderPage($viewDir, 'kontak', [
'email' => '[email protected]',
'tahun' => '2026',
]);
Output yang Dihasilkan:
<!DOCTYPE html>
<html lang="id">
<head>
<title>Hubungi Kami</title>
</head>
<body>
<nav>Beranda | Produk | Kontak</nav>
<main>
<h1>Hubungi Kami</h1>
<p>Email: <strong>[email protected]</strong></p>
</main>
<footer>Toko Budi © 2026</footer>
</body>
</html>Baca output dari dua sisi sekaligus. Dari sisi layout: judul halaman Hubungi Kami berasal dari $sections->get('judul'), bukan ditulis mati di layout. Dari sisi halaman anak: seluruh kerangka — navigasi, main, footer — datang dari layout tanpa halaman anak menuliskannya. Ketika navigasi berubah, hanya layout.php yang disentuh. Perhatikan juga bahwa variabel data ($email, $tahun) tetap di-escape dengan e() meskipun kontennya tampak aman — escaping adalah kebiasaan, bukan keputusan per kasus.
Sinergi dengan Alternative Syntax #
Template adalah tempat lahirnya alternative syntax. Potongan template daftar user berikut memperlihatkan betapa rapiya foreach ... endforeach membaur dengan HTML dibanding kurung kurawal yang mudah tersesat:
<?php
/* potongan berkas template users.php */
$users = $users ?? [];
?>
<ul>
<?php foreach ($users as $user): ?>
<li><?= e($user['nama']) ?> — <?= e($user['peran']) ?></li>
<?php endforeach ?>
</ul>
Kombinasi foreach/endforeach plus <?= e(...) ?> per item adalah pola 90% template aplikasi nyata. Mesin template kita tidak melakukan transformasi khusus untuk ini — include menjalankan template sebagai PHP biasa, jadi semua fitur bahasa (kondisi, loop, match) otomatis tersedia.
Perbandingan dengan Twig dan Blade #
Mesin yang kita bangun fungsional, tetapi ekosistem PHP punya dua pemain besar: Twig (dipakai Symfony) dan Blade (dipakai Laravel). Keduanya mengganti sintaks PHP dengan bahasa template yang lebih ringkas:
{# templates/produk.twig — Twig #}
<article>
<h2>{{ produk.nama }}</h2>
<p>Harga: Rp {{ produk.harga|number_format(0, ',', '.') }}</p>
</article>{{-- resources/views/produk.blade.php — Blade --}}
<article>
<h2>{{ $produk['nama'] }}</h2>
<p>Harga: Rp {{ number_format($produk['harga'], 0, ',', '.') }}</p>
</article>| Aspek | Template PHP native | Twig | Blade |
|---|---|---|---|
| Eksekusi | langsung oleh PHP | dikompilasi ke PHP | dikompilasi ke PHP |
| Escaping default | manual e() | otomatis {{ }} | otomatis {{ }} |
| Layout & section | kita bangun sendiri | inheritance bawaan | @extends, @section |
| Ketergantungan | nol | package terpisah | menyatu dengan Laravel |
Perbedaan paling penting justru yang tak terlihat di sintaks: auto-escape. Di Twig dan Blade, {{ }} sudah meng-escape otomatis — lupa meng-escape jadi hampir mustahil. Template PHP native menempatkan disiplin itu di tangan kita; satu <?= $nama ?> tanpa e() adalah pintu masuk XSS. Itulah harga dari ketergantungan nol: kebebasan penuh, tanggung jawab penuh.
Ringkasan #
Mesin template PHP pada akhirnya adalah komposisi tiga alat bawaan: extract() mengubah array menjadi variabel, output buffering menampung dan menyusun output, dan include menjalankan template sebagai PHP biasa. Helper e() menjaga setiap tempat data ditempel tetap aman dari XSS, sementara SectionStack dan layout memisahkan kerangka halaman dari isinya. Twig dan Blade menawarkan kenyamanan di atas mekanisme yang sama — terutama auto-escape — dengan bayaran ketergantungan. Mesin kita kini bisa menghasilkan Response berisi HTML utuh, dan helper view() di controller tinggal memanggilnya. Pertanyaan berikutnya: siapa yang menyusun controller beserta seluruh dependensinya? Itu pekerjaan Dependency Container
.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
view.php
Unduh berkas<?php
declare(strict_types=1);
/**
* Mesin template mini — inti chapter Template Rendering.
*
* Berisi helper escaping e(), fungsi render(), SectionStack, dan
* renderPage(); setiap contoh chapter memuat berkas ini dengan require.
*/
/** Meng-escape teks agar aman ditempel di dalam HTML. */
function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
/** Merender satu berkas template menjadi string. */
function render(string $templateFile, array $data): string
{
extract($data, EXTR_SKIP);
ob_start();
include $templateFile;
return (string) ob_get_clean();
}
final class SectionStack
{
/** @var array<string, string> */
private array $sections = [];
/** @var list<string> */
private array $open = [];
public function start(string $name): void
{
$this->open[] = $name;
ob_start();
}
public function stop(): void
{
$name = (string) array_pop($this->open);
$this->sections[$name] = (string) ob_get_clean();
}
public function get(string $name, string $default = ''): string
{
return $this->sections[$name] ?? $default;
}
}
/** Merender halaman anak (mengisi section) lalu membungkusnya dengan layout. */
function renderPage(string $viewDir, string $template, array $data, string $layout = 'layout'): string
{
$sections = new SectionStack();
extract($data, EXTR_SKIP);
ob_start();
include $viewDir . '/' . $template . '.php';
ob_end_clean();
ob_start();
include $viewDir . '/' . $layout . '.php';
return (string) ob_get_clean();
}