Mengirim berkas sebagai unduhan terdengar sepele — bukankah cukup memberikan tautan ke berkasnya? Persoalannya: tanpa header yang tepat, browser mencoba menampilkan isi berkas alih-alih menyimpannya, nama berkas asli bisa hilang, dan berkas besar bisa menghabiskan memori server. Respons unduhan yang benar adalah karya empat header — Content-Type, Content-Disposition, Content-Length, dan terkadang Content-Encoding — ditambah strategi pengiriman isinya. Chapter ini membahas pembangunan header unduhan (termasuk nama berkas non-ASCII memakai filename* RFC 5987), pengiriman lewat readfile() dan streaming potongan demi potongan, pencatatan jumlah unduhan, sampai pola X-Sendfile yang menyerahkan pembacaan berkas kepada server web.
Content-Disposition dan Nama Berkas #
Header Content-Disposition adalah saklar yang memindahkan browser dari mode menampilkan ke mode menyimpan. Nilai attachment memicu dialog unduhan, dan parameter filename menyertakan nama yang diusulkan:
<?php
function headerUnduhan(string $namaFile, ?int $ukuran = null): array
{
$fallback = preg_replace('/[^A-Za-z0-9._-]/u', "_", $namaFile);
$headers = [
"Content-Type: application/octet-stream",
"Content-Disposition: attachment; "
. "filename=\"$fallback\"; "
. "filename*=UTF-8''" . rawurlencode($namaFile),
];
if ($ukuran !== null) {
$headers[] = "Content-Length: $ukuran";
}
return $headers;
}
foreach (headerUnduhan("Laporan Penjualan Januari 2026.csv", 4096) as $header) {
echo $header . "\n";
}
echo "---\n";
foreach (headerUnduhan("Riwayat Résumé Siti.pdf") as $header) {
echo $header . "\n";
}Output yang Dihasilkan:
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="Laporan_Penjualan_Januari_2026.csv"; filename*=UTF-8''Laporan%20Penjualan%20Januari%202026.csv
Content-Length: 4096
---
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="Riwayat_R_sum__Siti.pdf"; filename*=UTF-8''Riwayat%20R%C3%A9sum%C3%A9%20Siti.pdfMari kita bedah fungsinya:
Content-Type: application/octet-streammenyatakan “byte umum” sehingga browser tidak mencoba mengeksekusi atau menampilkan isinya — untuk memaksa unduhan, ini lebih andal daripada Content-Type asli berkas.$fallbackmembersihkan nama berkas menjadi ASCII: spasi dan karakter sepertiédiganti_(dengan modifier/uagar satu karakter UTF-8 menjadi satu underscore, bukan dua). Parameterfilenamelama hanya aman memuat nilai ASCII inilah.filename*=UTF-8''...adalah bentuk RFC 5987: awalanUTF-8''lalu nama berkas asli dalamrawurlencode(). Browser modern memilihfilename*bila ada dan mengabaikanfilename— pada output kedua, namaRiwayat Résumé Siti.pdfutuh dengan karakter%C3%A9untuké, sementarafilenamecadangannya menjadiRiwayat_R_sum__Siti.pdf.Content-Length: 4096memberi tahu ukuran pasti sehingga browser bisa menampilkan progres unduhan.
Mengirim Berkas dengan readfile() #
readfile() membaca berkas dan langsung menuliskannya ke output — satu panggilan untuk seluruh isi, tanpa perlu memuat berkas ke variabel:
<?php
$berkasCsv = sys_get_temp_dir() . "/unduhan-demo-katalog.csv";
file_put_contents($berkasCsv, "produk,jumlah\nKopi Torabika,12\nGula Aren,5\n");
echo "ukuran berkas: " . filesize($berkasCsv) . " byte\n";
echo "readfile() mengirim isinya lalu mengembalikan: " . readfile($berkasCsv) . " byte\n";
unlink($berkasCsv);Output yang Dihasilkan:
ukuran berkas: 43 byte
produk,jumlah
Kopi Torabika,12
Gula Aren,5
readfile() mengirim isinya lalu mengembalikan: 43 byteUrutan pada output perlu diamati: isi berkas (tiga baris CSV) tercetak lebih dulu karena readfile() menulisnya saat ekspresi dievaluasi, baru angka yang dikembalikannya menyusul. Nilai kembalian sebesar ukuran berkas (43 byte) berguna untuk verifikasi — nilai false berarti berkas gagal dibaca, misalnya karena path salah atau izin kurang. Pada skrip web nyata, seluruh header dari fungsi headerUnduhan() dipasang lebih dulu, diikuti readfile($berkas) dan exit; agar tidak ada output lain yang ikut terkirim.
Streaming Potongan demi Potongan untuk Berkas Besar #
readfile() membaca berkas dalam potongan internal sehingga memori tetap terjaga, tetapi kendali penuh atas ukuran potongan dan momen pengiriman membutuhkan loop fread() sendiri:
<?php
$berkasDemo = sys_get_temp_dir() . "/unduhan-demo-besar.bin";
file_put_contents($berkasDemo, str_repeat("PHP-8.4 ", 300));
$stream = fopen($berkasDemo, "rb");
$total = 0;
$jumlahPotongan = 0;
while (!feof($stream)) {
$potongan = fread($stream, 1024);
$total += strlen($potongan);
$jumlahPotongan++;
// di skrip web: echo $potongan; flush();
}
fclose($stream);
unlink($berkasDemo);
printf("terkirim %d byte dalam %d potongan 1 KiB\n", $total, $jumlahPotongan);Output yang Dihasilkan:
terkirim 2400 byte dalam 3 potongan 1 KiBPola di atas membaca 1024 byte per iterasi hingga penunjuk berkas mencapai akhir (feof()); berkas 2400 byte terkirim dalam 3 potongan. Pada skrip web, setiap potongan langsung di-echo dan di-flush() sehingga pengguna mulai menerima data lebih awal dan memori server hanya menampung satu potongan berapa pun ukuran berkasnya. Sebagai alternatif tanpa loop, fpassthru() menuliskan seluruh sisa stream sekaligus:
<?php
$berkasDemo = sys_get_temp_dir() . "/unduhan-demo-fpassthru.bin";
file_put_contents($berkasDemo, "data-biner-demo-15b");
$stream = fopen($berkasDemo, "rb");
$jumlah = fpassthru($stream);
fclose($stream);
unlink($berkasDemo);
echo "\nfpassthru() menulis $jumlah byte ke output\n";Output yang Dihasilkan:
data-biner-demo-15b
fpassthru() menulis 19 byte ke outputSama seperti readfile(), fpassthru() mengembalikan jumlah byte yang tertulis — tetapi sumbernya adalah stream yang sudah terbuka, sehingga cocok saat Anda perlu memeriksa berkas lebih dulu (misalnya membaca header format) sebelum mengirim isinya. Untuk berkas yang harus dihitung/dibangkitkan, strategi streaming yang lebih lanjut dibahas di chapter streaming large responses
.
Mencatat Jumlah Unduhan #
Unduhan yang diproses lewat PHP (alih-alih tautan statis) membuka kemampuan mencatatnya. Tabel kecil di SQLite dengan operasi upsert menaikkan pencacah per nama berkas:
<?php
$db = new PDO("sqlite::memory:");
$db->exec("CREATE TABLE unduhan (nama_file TEXT PRIMARY KEY, jumlah INTEGER NOT NULL)");
function catatUnduhan(PDO $db, string $namaFile): int
{
$upsert = $db->prepare(
"INSERT INTO unduhan (nama_file, jumlah) VALUES (?, 1)
ON CONFLICT(nama_file) DO UPDATE SET jumlah = jumlah + 1"
);
$upsert->execute([$namaFile]);
$ambil = $db->prepare("SELECT jumlah FROM unduhan WHERE nama_file = ?");
$ambil->execute([$namaFile]);
return (int) $ambil->fetchColumn();
}
foreach (["katalog-2026.pdf", "katalog-2026.pdf", "daftar-harga.xlsx"] as $unduhan) {
printf("%-18s => diunduh %d kali\n", $unduhan, catatUnduhan($db, $unduhan));
}Output yang Dihasilkan:
katalog-2026.pdf => diunduh 1 kali
katalog-2026.pdf => diunduh 2 kali
daftar-harga.xlsx => diunduh 1 kaliKlausa ON CONFLICT ... DO UPDATE menggabungkan dua kasus — baris baru dan baris yang sudah ada — menjadi satu statement yang aman dipanggil berulang. Pada output, katalog-2026.pdf tercatat dua kali dari dua pemanggilan berturut-turut. Di produksi, pencatatan seperti ini dipindahkan ke tabel dengan kolom waktu agar bisa dianalisis trennya; dasar SQLite-nya sendiri sudah kita kuasai di section database dan SQL
.
X-Sendfile: Menyerahkan Tugas ke Server Web #
Untuk berkas besar yang tidak butuh diproses, PHP bisa mundur dan meminta server web yang membaca berkasnya. Cukup satu header khusus:
<?php
// unduh-terlindungi.php — memerlukan modul X-Sendfile (Apache)
// atau X-Accel-Redirect (nginx) yang aktif di server web
$berkasFisik = "/data-internal/laporan-rahasia-2026.pdf";
header("X-Sendfile: $berkasFisik");
header("Content-Type: application/pdf");
header("Content-Disposition: attachment; filename=\"laporan-rahasia-2026.pdf\"");Modul yang sesuai membaca header X-Sendfile, mengirimkan berkas langsung dari server web, dan mengabaikan body dari PHP — proses PHP selesai lebih cepat dan buffer tidak dipakai untuk isi berkas. Berkat itu, berkas boleh berada di luar document root, tetap terlindungi pemeriksaan izin yang Anda tulis di PHP sebelum header dipasang. Tanpa modulnya, header ini diabaikan begitu saja — dan justru berbahaya: path internal ikut terkirim ke klien. Selalu pastikan modul aktif, atau gunakan pola streaming di atas.
Menguji di Server Web Sungguhan #
Mari kita rangkai seluruh bagian menjadi endpoint unduhan sungguhan:
<?php
// unduh-katalog.php — dari folder berkas ini, jalankan:
// php -S 127.0.0.1:8089
// lalu amati headernya:
// curl -i http://127.0.0.1:8089/unduh-katalog.php
// dan simpan berkasnya:
// curl -OJ http://127.0.0.1:8089/unduh-katalog.php
$namaFile = "Laporan Penjualan Januari 2026.csv";
$isi = "produk,jumlah\nKopi Torabika,12\nGula Aren,5\n";
header("Content-Type: text/csv; charset=UTF-8");
header("Content-Disposition: attachment; filename=\"laporan.csv\"; filename*=UTF-8''" . rawurlencode($namaFile));
header("Content-Length: " . strlen($isi));
echo $isi;Respons curl -i menampilkan keempat header lengkap: Content-Type: text/csv; charset=UTF-8, Content-Disposition dengan kedua bentuk nama berkas, dan Content-Length: 43 di atas body CSV. Dengan curl -OJ, berkas tersimpan memakai parameter filename cadangan (laporan.csv) karena curl memilih nilai sederhana itu; browser modern sebaliknya memilih filename* sehingga nama yang muncul di dialog unduhan adalah Laporan Penjualan Januari 2026.csv utuh dengan spasi. Matikan server setelah selesai dengan Ctrl+C atau pkill -f "php -S".
Ringkasan #
Respons unduhan dibangun dari Content-Disposition: attachment plus pasangan filename ASCII dan filename* RFC 5987 untuk nama non-ASCII, Content-Type umum agar browser tidak menampilkan isi, dan Content-Length untuk progres. Isinya dikirim lewat readfile() untuk kasus sederhana, loop fread() + flush() atau fpassthru() untuk kendali penuh atas berkas besar, sementara pencatatan unduhan tinggal satu statement upsert. Untuk berkas statis raksasa, X-Sendfile memindahkan pekerjaan ke server web. Topik respons khusus berikutnya jauh lebih pendek namun sama pentingnya: redirect — mengarahkan klien ke URL lain dengan kode status yang tepat.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
contoh.php
Unduh berkas<?php
// contoh.php — ringkasan respons unduhan berkas: header unduhan
// dengan filename* RFC 5987, pengiriman lewat readfile(), dan
// pencatatan jumlah unduhan di SQLite.
function headerUnduhan(string $namaFile, ?int $ukuran = null): array
{
$fallback = preg_replace('/[^A-Za-z0-9._-]/u', "_", $namaFile);
$headers = [
"Content-Type: application/octet-stream",
"Content-Disposition: attachment; "
. "filename=\"$fallback\"; "
. "filename*=UTF-8''" . rawurlencode($namaFile),
];
if ($ukuran !== null) {
$headers[] = "Content-Length: $ukuran";
}
return $headers;
}
echo "=== Header unduhan untuk nama non-ASCII ===\n";
foreach (headerUnduhan("Laporan Penjualan Januari 2026.csv", 4096) as $header) {
echo $header . "\n";
}
echo "---\n";
foreach (headerUnduhan("Riwayat Résumé Siti.pdf") as $header) {
echo $header . "\n";
}
echo "=== Pengiriman lewat readfile() ===\n";
$berkasCsv = sys_get_temp_dir() . "/unduhan-demo-katalog.csv";
file_put_contents($berkasCsv, "produk,jumlah\nKopi Torabika,12\nGula Aren,5\n");
echo "ukuran berkas: " . filesize($berkasCsv) . " byte\n";
echo "readfile() mengirim isinya lalu mengembalikan: " . readfile($berkasCsv) . " byte\n";
unlink($berkasCsv);
echo "=== Pencatatan unduhan (SQLite in-memory) ===\n";
$db = new PDO("sqlite::memory:");
$db->exec("CREATE TABLE unduhan (nama_file TEXT PRIMARY KEY, jumlah INTEGER NOT NULL)");
function catatUnduhan(PDO $db, string $namaFile): int
{
$upsert = $db->prepare(
"INSERT INTO unduhan (nama_file, jumlah) VALUES (?, 1)
ON CONFLICT(nama_file) DO UPDATE SET jumlah = jumlah + 1"
);
$upsert->execute([$namaFile]);
$ambil = $db->prepare("SELECT jumlah FROM unduhan WHERE nama_file = ?");
$ambil->execute([$namaFile]);
return (int) $ambil->fetchColumn();
}
foreach (["katalog-2026.pdf", "katalog-2026.pdf", "daftar-harga.xlsx"] as $unduhan) {
printf("%-18s => diunduh %d kali\n", $unduhan, catatUnduhan($db, $unduhan));
}