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.

html
<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:

  1. 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 $_FILES yang kosong.
  2. accept="image/png,...": hanya menyaring pilihan di dialog pemilih berkas. Atribut ini kenyamanan, bukan pengamanan.
  3. 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
<?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:

text
Array
(
    [name] => foto-budi.png
    [type] => image/png
    [size] => 204800
    [tmp_name] => /tmp/phpA1b2C3
    [error] => 0
)

Lima kunci tersebut memiliki tingkat kepercayaan yang berbeda:

KunciMaknaBisa dipercaya?
nameNama berkas di komputer penggunaTidak — pengguna bebas menamainya apa pun
typeKlaim jenis MIME dari browserTidak — anggap sebagai teks fiksi
sizeUkuran berkas dalam byteYa — dihitung di server
tmp_namePath sementara berkas di serverYa — dibuat oleh PHP sendiri
errorKode status proses uploadYa — 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
<?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:

text
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 terunggah

Kode 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
<?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:

text
Valid: 0 error
Palsu: 1 error
  - Isi berkas bukan gambar (terdeteksi: text/x-php).

Mari kita bedah lapisan-lapisan validasinya:

  1. Kode error lebih dulu: berkas yang gagal terunggah tidak punya isi yang layak diperiksa, jadi fungsi langsung kembali dengan satu pesan.
  2. Ekstensi dengan allowlist: ['jpg', 'png', 'webp'] berisi daftar yang diizinkan, bukan daftar yang dilarang. Allowlist lebih aman karena jenis berkas jahat baru otomatis tertolak.
  3. finfo membaca isi berkas: kelas dari ekstensi Fileinfo memeriksa magic bytes — penanda biner di awal berkas — pada tmp_name. Pada sesi latihan ini kita membuat dua berkas sungguhan (PNG sah berukuran 45 byte dan skrip PHP yang menyamar ber-ekstensi .png) untuk membuktikan finfo tidak tertipu nama berkas.
  4. 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
<?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:

  1. bin2hex(random_bytes(16)) menghasilkan nama acak seperti 3f2a9c...e1.png. Nama acak mencegah tabrakan nama antar pengguna, mencegah nama berkas berbahaya seperti ../../laravel-config.php dipakai sebagai path, dan mencegah berkas tertimpa.
  2. 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.
  3. 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:

html
<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
<?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:

text
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 .

Referensi #