Mengunggah berkas — foto profil, lampiran dokumen, gambar produk — adalah salah satu fitur form yang paling sering diminta, sekaligus yang paling sering disalahgunakan. Kabar baiknya: PHP menyiapkan seluruh perpipaan teknisnya, mulai dari penanganan multipart/form-data sampai array superglobal $_FILES. Tugas kita sebagai penulis aplikasi adalah memvalidasi berkas dengan sungguh-sungguh sebelum menyimpannya, karena hampir semua nilai yang berhubungan dengan upload adalah klaim dari klien yang boleh dipalsukan. Chapter ini berfokus pada sisi form; untuk strategi penyimpanan jangka panjang, struktur direktori, dan izin berkas, pembahasan mendalam ada di chapter Uploads dan File Storage
.
Form dengan enctype multipart/form-data #
Form upload punya dua syarat yang membedakannya dari form biasa: method="post" dan enctype="multipart/form-data". Tanpa enctype tersebut, browser menyandikan semua isian dengan format application/x-www-form-urlencoded — format yang merusak berkas biner.
<form action="/upload.php" method="post" enctype="multipart/form-data">
<label for="foto">Foto profil (jpg, png, atau webp, maks 2 MB)</label>
<input type="file" id="foto" name="foto"
accept="image/png,image/jpeg,image/webp">
<input type="hidden" name="MAX_FILE_SIZE" value="2097152">
<button type="submit">Unggah</button>
</form>Mari kita periksa atribut-atribut penting pada markup di atas:
enctype="multipart/form-data": memberi tahu browser untuk mengemas setiap bagian body secara terpisah sehingga berkas biner sampai utuh. Lupa atribut ini adalah penyebab nomor satu$_FILESyang kosong.accept="image/png,...": hanya menyaring pilihan di dialog pemilih berkas. Atribut ini kenyamanan, bukan pengamanan.name="MAX_FILE_SIZE"(nilai dalam byte, di sini 2 MB): hidden field khusus yang dibaca PHP; browser yang kooperatif akan memblokir berkas yang lebih besar sebelum mengirim.
Anatomi Superglobal $_FILES #
Ketika form terkirim, PHP menyimpan berkas sementara di direktori temporary server, lalu mendeskripsikannya dalam $_FILES yang dikunci oleh atribut name pada input. Struktur inilah bahan kerja kita:
<?php
// Simulasi $_FILES yang dikirim PHP untuk input name="foto"
$_FILES['foto'] = [
'name' => 'foto-budi.png',
'type' => 'image/png',
'size' => 204800,
'tmp_name' => '/tmp/phpA1b2C3',
'error' => 0,
];
print_r($_FILES['foto']);Output yang Dihasilkan:
Array
(
[name] => foto-budi.png
[type] => image/png
[size] => 204800
[tmp_name] => /tmp/phpA1b2C3
[error] => 0
)Lima kunci tersebut memiliki tingkat kepercayaan yang berbeda:
| Kunci | Makna | Bisa dipercaya? |
|---|---|---|
name | Nama berkas di komputer pengguna | Tidak — pengguna bebas menamainya apa pun |
type | Klaim jenis MIME dari browser | Tidak — anggap sebagai teks fiksi |
size | Ukuran berkas dalam byte | Ya — dihitung di server |
tmp_name | Path sementara berkas di server | Ya — dibuat oleh PHP sendiri |
error | Kode status proses upload | Ya — diisi oleh PHP sendiri |
Dua kunci pertama adalah input dari orang asing; tiga sisanya fakta yang dihasilkan server. Pemisahan mental ini menjadi dasar seluruh strategi validasi pada bagian berikutnya.
Membaca Kode Error Upload #
Pemeriksaan pertama yang harus dilakukan adalah error, sebelum menyentuh berkasnya sama sekali. PHP menyediakan konstanta bernama agar kode angka tidak perlu dihafal:
<?php
$pesanErrorUpload = [
UPLOAD_ERR_OK => 'Unggahan berhasil',
UPLOAD_ERR_INI_SIZE => 'Ukuran melebihi batas upload_max_filesize',
UPLOAD_ERR_FORM_SIZE => 'Ukuran melebihi MAX_FILE_SIZE pada form',
UPLOAD_ERR_PARTIAL => 'Berkas hanya terunggah sebagian',
UPLOAD_ERR_NO_FILE => 'Tidak ada berkas yang terunggah',
];
foreach ($pesanErrorUpload as $kode => $pesan) {
printf("%d => %s\n", $kode, $pesan);
}Output yang Dihasilkan:
0 => Unggahan berhasil
1 => Ukuran melebihi batas upload_max_filesize
2 => Ukuran melebihi MAX_FILE_SIZE pada form
3 => Berkas hanya terunggah sebagian
4 => Tidak ada berkas yang terunggahKode 0 berarti sukses, 1 dan 2 berarti terlalu besar (masing-masing ditolak PHP dan ditolak browser), 3 upload terputus di tengah jalan, dan 4 field memang dibiarkan kosong. Masih ada kode 6, 7, dan 8 yang menandakan masalah konfigurasi server (direktori temporary hilang, gagal menulis, ekstensi memblokir) — daftar lengkapnya ada di manual PHP. Membedakan 4 dari kode lain berguna: field opsional yang dibiarkan kosong bukanlah kesalahan yang perlu disampaikan ke pengguna.
Alur Validasi Sebelum Menyimpan #
Validasi upload yang benar memeriksa empat hal secara berurutan: kode error, ukuran, ekstensi, dan isi berkas yang sesungguhnya. Fungsi berikut mengembalikan daftar pesan error sehingga bisa langsung dimasukkan ke pola error bag pada chapter berikutnya:
<?php
function validasiUpload(array $berkas, int $ukuranMaks = 2097152): array
{
$errors = [];
if (($berkas['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
$errors[] = 'Unggahan gagal atau tidak ada berkas.';
return $errors;
}
if ($berkas['size'] > $ukuranMaks) {
$errors[] = 'Ukuran berkas melebihi 2 MB.';
}
$ekstensi = strtolower(pathinfo($berkas['name'], PATHINFO_EXTENSION));
if (!in_array($ekstensi, ['jpg', 'png', 'webp'], true)) {
$errors[] = 'Ekstensi hanya boleh jpg, png, atau webp.';
}
// finfo membaca isi berkas, bukan nama atau klaim browser
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeAsli = $finfo->file($berkas['tmp_name']);
if (!in_array($mimeAsli, ['image/jpeg', 'image/png', 'image/webp'], true)) {
$errors[] = "Isi berkas bukan gambar (terdeteksi: {$mimeAsli}).";
}
return $errors;
}
// Dua "unggahan" simulasi; di aplikasi nyata PHP yang mengisi $_FILES
$direktoriTmp = sys_get_temp_dir();
// PNG minimal yang sah: signature + chunk IHDR + chunk IEND
$chunk = function (string $tipe, string $data): string {
$isi = $tipe . $data;
return pack('N', strlen($data)) . $isi . pack('N', crc32($isi));
};
$pngMinimal = "\x89PNG\r\n\x1a\n"
. $chunk('IHDR', pack('NNCCCCC', 1, 1, 8, 2, 0, 0, 0))
. $chunk('IEND', '');
$berkasPngAsli = $direktoriTmp . '/uji-asli.png';
file_put_contents($berkasPngAsli, $pngMinimal);
$berkasPalsu = $direktoriTmp . '/uji-palsu.png';
file_put_contents($berkasPalsu, "<?php echo 'ini bukan gambar';");
$fotoValid = [
'name' => 'foto-budi.png', 'type' => 'image/png',
'size' => 45, 'tmp_name' => $berkasPngAsli, 'error' => UPLOAD_ERR_OK,
];
$fotoPalsu = [
'name' => 'avatar.png', 'type' => 'image/png',
'size' => 29, 'tmp_name' => $berkasPalsu, 'error' => UPLOAD_ERR_OK,
];
echo 'Valid: ' . count(validasiUpload($fotoValid)) . " error\n";
echo 'Palsu: ' . count(validasiUpload($fotoPalsu)) . " error\n";
foreach (validasiUpload($fotoPalsu) as $error) {
echo " - {$error}\n";
}
unlink($berkasPngAsli);
unlink($berkasPalsu);Output yang Dihasilkan:
Valid: 0 error
Palsu: 1 error
- Isi berkas bukan gambar (terdeteksi: text/x-php).Mari kita bedah lapisan-lapisan validasinya:
- Kode error lebih dulu: berkas yang gagal terunggah tidak punya isi yang layak diperiksa, jadi fungsi langsung kembali dengan satu pesan.
- Ekstensi dengan allowlist:
['jpg', 'png', 'webp']berisi daftar yang diizinkan, bukan daftar yang dilarang. Allowlist lebih aman karena jenis berkas jahat baru otomatis tertolak. finfomembaca isi berkas: kelas dari ekstensi Fileinfo memeriksa magic bytes — penanda biner di awal berkas — padatmp_name. Pada sesi latihan ini kita membuat dua berkas sungguhan (PNG sah berukuran 45 byte dan skrip PHP yang menyamar ber-ekstensi.png) untuk membuktikanfinfotidak tertipu nama berkas.- Hasil demonstrasi: berkas PNG sah lolos tanpa error, sedangkan skrip menyamar ditolak dengan pesan yang menyebut MIME aslinya.
Menyimpan Berkas dengan Aman #
Setelah validasi lolos, berkas dipindahkan dari direktori sementara ke lokasi permanen. Praktik amannya terlihat seperti ini:
<?php
$errors = validasiUpload($_FILES['foto'] ?? []);
if ($errors !== []) {
// validasi gagal: render ulang form bersama $errors
return;
}
$ekstensi = strtolower(pathinfo($_FILES['foto']['name'], PATHINFO_EXTENSION));
$namaBaru = bin2hex(random_bytes(16)) . '.' . $ekstensi;
$tujuan = __DIR__ . '/uploads/' . $namaBaru;
if (!move_uploaded_file($_FILES['foto']['tmp_name'], $tujuan)) {
throw new RuntimeException('Gagal memindahkan berkas unggahan.');
}Blok di atas membutuhkan unggahan HTTP sungguhan agar move_uploaded_file() mau bekerja, sehingga tidak bisa dieksekusi pada sesi CLI — namun setiap barisnya tetap perlu dipahami:
bin2hex(random_bytes(16))menghasilkan nama acak seperti3f2a9c...e1.png. Nama acak mencegah tabrakan nama antar pengguna, mencegah nama berkas berbahaya seperti../../laravel-config.phpdipakai sebagai path, dan mencegah berkas tertimpa.move_uploaded_file()adalah satu-satunya fungsi yang tepat untuk memindahkan hasil upload: fungsi ini memverifikasi bahwa berkas memang diunggah lewat HTTP (is_uploaded_file()), menolak berkas lain yang mencoba menyamar, dan menolak menimpa berkas tujuan yang sudah ada.- Direktori tujuan idealnya berada di luar document root atau dikonfigurasi agar isinya tidak dieksekusi — detailnya dibahas di chapter Uploads dan File Storage dan File Upload Security .
Upload Banyak Berkas Sekaligus #
Dengan atribut multiple dan nama berakhir [], satu field menerima beberapa berkas sekaligus:
<form action="/upload-dokumen.php" method="post" enctype="multipart/form-data">
<label for="dokumen">Pilih satu atau beberapa berkas</label>
<input type="file" id="dokumen" name="dokumen[]" multiple>
<button type="submit">Unggah Semua</button>
</form>PHP merangkai hasilnya dengan struktur yang “tertransposisi”: setiap kunci menjadi array, dan setiap indeks mewakili satu berkas. Fungsi kecil berikut menormalkannya kembali menjadi daftar berkas per elemen agar mudah di-loop:
<?php
function normalisasiFiles(array $files): array
{
$hasil = [];
foreach ($files['name'] as $indeks => $nama) {
$hasil[] = [
'name' => $nama,
'type' => $files['type'][$indeks],
'size' => $files['size'][$indeks],
'tmp_name' => $files['tmp_name'][$indeks],
'error' => $files['error'][$indeks],
];
}
return $hasil;
}
// Struktur $_FILES untuk <input type="file" name="dokumen[]" multiple>
$dokumen = [
'name' => ['notulen-rapat.txt', 'anggaran.txt'],
'type' => ['text/plain', 'text/plain'],
'size' => [120, 340],
'tmp_name' => ['/tmp/phpY1', '/tmp/phpY2'],
'error' => [UPLOAD_ERR_OK, UPLOAD_ERR_PARTIAL],
];
foreach (normalisasiFiles($dokumen) as $nomor => $berkas) {
printf(
"%d. %s (%d byte, error=%d)\n",
$nomor + 1,
$berkas['name'],
$berkas['size'],
$berkas['error'],
);
}Output yang Dihasilkan:
1. notulen-rapat.txt (120 byte, error=0)
2. anggaran.txt (340 byte, error=3)Perhatikan pada output bahwa setiap berkas membawa kode error-nya sendiri: berkas pertama sukses (error=0) sedangkan berkas kedua terputus (error=3). Karena itu validasi multi-upload selalu memproses per berkas, bukan sekali untuk semua — satu berkus rusak tidak boleh membuat sisanya ikut ditolak, dan sebaliknya berkas yang valid tetap bisa diselamatkan.
Ringkasan #
Alur upload yang aman terdiri dari empat gerbang berurutan: form dengan enctype yang benar, pembacaan kode error, validasi size–ekstensi–isi berkas, dan penyimpanan dengan nama acak melalui move_uploaded_file(). Kunci mentalnya adalah memperlakukan name dan type sebagai klaim klien, dan hanya memercayai fakta yang dihitung server. Pesan-pesan error yang dikembalikan fungsi validasiUpload() siap dimasukkan ke pola tampilan yang akan kita bangun pada chapter berikutnya: Menampilkan Validation Errors
.