Upload adalah jalur masuk data pengguna yang paling sering ada sekaligus paling berisiko: satu celah validasi bisa berujung pada berkas berbahaya yang tersimpan dan dieksekusi di server Anda. Kabar baiknya, PHP menangani seluruh transport upload secara otomatis — mulai dari menerima data multipart hingga menyimpannya sementara di server — dan menyisakan dua tugas bagi kita: memvalidasi berkas yang tiba dan menyimpannya dengan aman. Pada Chapter ini kita bedah struktur $_FILES, kode error UPLOAD_ERR_*, logika validasi, serta strategi penyimpanan. Karena mekanisme upload hanya hidup pada request HTTP sungguhan, contoh logika inti diuji di CLI dengan array $_FILES buatan — persis seperti pengujian otomatis melakukannya.

Alur Kerja Upload di PHP #

Semuanya bermula dari form HTML dengan dua syarat wajib: method="post" dan enctype="multipart/form-data". Tanpa atribut kedua, browser hanya mengirim nama berkas sebagai teks, bukan isinya.

html
<form action="proses-upload.php" method="post" enctype="multipart/form-data">
    <input type="file" name="dokumen">
    <button type="submit">Unggah</button>
</form>

Saat form dikirim, PHP menerima berkas, menyimpannya sementara di direktori temporer (seperti /tmp/phpA1b2C3), lalu mengisi array superglobal $_FILES dengan informasinya. Skrip Anda kemudian memvalidasi dan memindahkan berkas tersebut dengan move_uploaded_file(); jika tidak dipindahkan, PHP menghapus berkas temporernya secara otomatis saat request berakhir.

Diagram di atas merangkum siklus hidup lengkap sebuah upload: dari komputer pengguna, masuk sebagai berkas temporer di server, lalu berakhir di penyimpanan permanen bila lolos validasi atau terhapus otomatis bila tidak. Seluruh sisa Chapter ini membahas kotak keputusan di tengah diagram tersebut beserta tujuan akhirnya.

Struktur Array $_FILES #

Setelah request upload diproses, $_FILES["dokumen"] — kuncinya diambil dari atribut name pada input — berisi lima informasi dengan tingkat kepercayaan yang tidak sama:

KunciIsiSumber
nameNama berkas asli di komputer penggunaDikirim browser
typeKlaim tipe MIME berkasDikirim browser
tmp_namePath berkas temporer di serverDibuat PHP
errorKode hasil uploadDibuat PHP
sizeUkuran berkas dalam byteDibuat PHP

Karena mekanisme upload tidak berjalan di CLI, contoh berikut mengisi $_FILES secara manual — tepat seperti yang PHP lakukan otomatis pada request nyata:

php
<?php
$_FILES["dokumen"] = [
    "name"     => "laporan-keuangan.pdf",
    "type"     => "application/pdf",
    "tmp_name" => "/tmp/phpA1b2C3",
    "error"    => UPLOAD_ERR_OK,
    "size"     => 20480,
];

$info = $_FILES["dokumen"];
echo "Nama asli: " . $info["name"] . "\n";
echo "Ukuran: " . $info["size"] . " byte\n";
echo "Kode error: " . $info["error"] . "\n";

Output yang Dihasilkan:

text
Nama asli: laporan-keuangan.pdf
Ukuran: 20480 byte
Kode error: 0

Membaca Kode Error UPLOAD_ERR_* #

Pemeriksaan pertama yang wajib dilakukan pada setiap upload adalah kunci error, karena ukuran 0 (konstanta UPLOAD_ERR_OK) adalah satu-satunya tanda bahwa berkas benar-benar tiba utuh. PHP menyediakan sekumpulan konstanta bernama untuk setiap kemungkinan kegagalan:

php
<?php
$pesanError = [
    UPLOAD_ERR_OK         => "Upload berhasil",
    UPLOAD_ERR_INI_SIZE   => "Melebihi upload_max_filesize di php.ini",
    UPLOAD_ERR_FORM_SIZE  => "Melebihi MAX_FILE_SIZE pada form HTML",
    UPLOAD_ERR_PARTIAL    => "Hanya sebagian berkas terunggah",
    UPLOAD_ERR_NO_FILE    => "Tidak ada berkas yang dipilih",
    UPLOAD_ERR_NO_TMP_DIR => "Folder temporary tidak ada",
    UPLOAD_ERR_CANT_WRITE => "Gagal menulis berkas ke disk",
    UPLOAD_ERR_EXTENSION  => "Upload dihentikan ekstensi PHP",
];

foreach ($pesanError as $kode => $pesan) {
    echo $kode . ": " . $pesan . "\n";
}

Output yang Dihasilkan:

text
0: Upload berhasil
1: Melebihi upload_max_filesize di php.ini
2: Melebihi MAX_FILE_SIZE pada form HTML
3: Hanya sebagian berkas terunggah
4: Tidak ada berkas yang dipilih
6: Folder temporary tidak ada
7: Gagal menulis berkas ke disk
8: Upload dihentikan ekstensi PHP

Konstanta-konstanta ini adalah konstanta global, bukan konstanta class, sehingga langsung dipakai tanpa awalan apa pun. Dua kegagalan pertama sangat umum di produksi: batas ukuran dikendalikan directive upload_max_filesize dan post_max_size di php.ini, dan keduanya hanya bisa diubah pada level konfigurasi server — validasi JavaScript di sisi klien tidak akan pernah menggantikannya.

Validasi Upload sebagai Fungsi Murni #

Kumpulkan seluruh aturan validasi ke dalam satu fungsi yang menerima array berkas dan mengembalikan keputusan. Dengan bentuk fungsi murni semacam ini, seluruh logika dapat diuji tanpa web server — cukup dengan array $_FILES buatan seperti contoh-contoh sebelumnya, persis cara pengujian otomatis mengujinya.

php
<?php
const EKSTENSI_DIIZINKAN = ["jpg", "jpeg", "png", "pdf"];
const UKURAN_MAKSIMAL = 2 * 1024 * 1024;

function validasiUpload(array $berkas): array
{
    if ($berkas["error"] !== UPLOAD_ERR_OK) {
        return [false, "Upload gagal (kode " . $berkas["error"] . ")"];
    }

    if ($berkas["size"] > UKURAN_MAKSIMAL) {
        return [false, "Ukuran melebihi batas 2 MB"];
    }

    $ekstensi = strtolower(pathinfo($berkas["name"], PATHINFO_EXTENSION));
    if (!in_array($ekstensi, EKSTENSI_DIIZINKAN, true)) {
        return [false, "Ekstensi ." . $ekstensi . " tidak diizinkan"];
    }

    return [true, "Berkas lolos validasi"];
}

$uploadValid = [
    "name" => "foto-liburan.jpg", "type" => "image/jpeg",
    "tmp_name" => "/tmp/phpX1y2Z3", "error" => UPLOAD_ERR_OK, "size" => 512000,
];
$uploadTolak = [
    "name" => "skrip.php", "type" => "application/x-php",
    "tmp_name" => "/tmp/phpX4y5Z6", "error" => UPLOAD_ERR_OK, "size" => 2048,
];

[$ok, $pesan] = validasiUpload($uploadValid);
echo ($ok ? "OK" : "GAGAL") . " — " . $pesan . "\n";

[$ok, $pesan] = validasiUpload($uploadTolak);
echo ($ok ? "OK" : "GAGAL") . " — " . $pesan . "\n";

Output yang Dihasilkan:

text
OK — Berkas lolos validasi
GAGAL — Ekstensi .php tidak diizinkan

Mari kita telaah urutan pemeriksaannya: kode error diperiksa paling awal karena tidak ada gunanya memvalidasi berkas yang tidak pernah tiba, lalu ukuran dibandingkan dengan konstanta batas, dan terakhir ekstensi diurai dengan pathinfo() lalu dinormalkan ke huruf kecil sebelum dicocokkan ke daftar putih dengan in_array() mode ketat. Perhatikan bahwa ekstensi diambil hanya dari name untuk keperluan penamaan — keputusan keamanan yang sesungguhnya sebaiknya memakai pemeriksaan isi berkas dengan finfo.

is_uploaded_file() dan move_uploaded_file() #

Kunci tmp_name diisi oleh PHP sendiri, sehingga secara normal pasti menunjuk berkas hasil upload sungguhan. Namun kode yang menerima array tiruan — misalnya karena bug atau data yang dipalsukan sebelum mencapai fungsi Anda — bisa membawa path sembarangan. Fungsi is_uploaded_file() menjawab keraguan ini: fungsi tersebut mengembalikan true hanya untuk berkas yang memang diunggah melalui HTTP POST pada request yang sedang berjalan.

php
<?php
$berkasPalsu = tempnam(sys_get_temp_dir(), "unggahan-");
var_dump(is_uploaded_file($berkasPalsu));
unlink($berkasPalsu);

Output yang Dihasilkan:

text
bool(false)

Hasilnya false karena kita berada di CLI — tidak ada mekanisme upload pada eksekusi skrip biasa. Pada request HTTP nyata, fungsi ini mengembalikan true hanya untuk berkas temporer upload pada request tersebut. Sementara itu, move_uploaded_file() menggabungkan pemeriksaan yang sama dengan pemindahan berkas: fungsi ini memvalidasi asal-usul berkas lalu memindahkannya ke tujuan secara aman dalam satu langkah, dan mengembalikan false bila salah satu tahap gagal.

Berikut endpoint lengkap yang merangkai seluruh bab sebelumnya. Blok ini membutuhkan web server dan request POST sungguhan, sehingga tidak dapat diverifikasi di CLI:

php
<?php
// proses-upload.php — endpoint penanganan upload (butuh web server + POST nyata)
const EKSTENSI_DIIZINKAN = ["jpg", "jpeg", "png", "pdf"];
const UKURAN_MAKSIMAL = 2 * 1024 * 1024;
const PATH_STORAGE = "/var/www/aplikasi/storage/uploads";

if ($_SERVER["REQUEST_METHOD"] !== "POST") {
    http_response_code(405);
    exit("Metode harus POST");
}

$berkas = $_FILES["dokumen"] ?? null;

if ($berkas === null || $berkas["error"] !== UPLOAD_ERR_OK) {
    http_response_code(422);
    exit("Upload tidak valid");
}

if ($berkas["size"] > UKURAN_MAKSIMAL) {
    http_response_code(422);
    exit("Ukuran melebihi 2 MB");
}

$ekstensi = strtolower(pathinfo($berkas["name"], PATHINFO_EXTENSION));
if (!in_array($ekstensi, EKSTENSI_DIIZINKAN, true)) {
    http_response_code(422);
    exit("Ekstensi tidak diizinkan");
}

// pastikan tmp_name benar-benar berasal dari upload, bukan dipalsukan
if (!is_uploaded_file($berkas["tmp_name"])) {
    http_response_code(422);
    exit("Bukan berkas upload yang sah");
}

$namaAman = bin2hex(random_bytes(16)) . "." . $ekstensi;
$subdirektori = PATH_STORAGE . "/" . substr($namaAman, 0, 2);

if (!is_dir($subdirektori)) {
    mkdir($subdirektori, 0775, true);
}

if (!move_uploaded_file($berkas["tmp_name"], $subdirektori . "/" . $namaAman)) {
    http_response_code(500);
    exit("Gagal menyimpan berkas");
}

echo "Berkas tersimpan sebagai " . $namaAman;

Strategi Penyimpanan yang Aman #

Validasi hanyalah setengah cerita; setengah lainnya adalah cara berkas disimpan. Ada tiga pilar yang saling melengkapi. Pertama, nama berkas baru selalu dibangkitkan secara acak dengan bin2hex(random_bytes(16)) — mencegah tabrakan nama, menghapus karakter berbahaya dari nama asli, dan membuat nama tidak bisa ditebak. Kedua, ekstensi dicocokkan terhadap daftar putih (allowlist) alih-alih daftar hitam, karena daftar hitam selalu ketinggalan satu varian baru. Ketiga, berkas disimpan di luar document root sebagaimana dibahas pada Chapter Filesystem Paths , lalu disajikan kembali melalui skrip PHP yang membaca berkas dengan readfile().

Saat jumlah berkas membengkak hingga ribuan, satu folder raksasa menjadi masalah performa filesystem itu sendiri. Teknik directory hashing mengatasinya dengan memakai beberapa karakter pertama nama acak sebagai subdirektori:

php
<?php
function pathPenyimpanan(string $namaAman): string
{
    $subdirektori = substr($namaAman, 0, 2);
    return "storage/uploads/" . $subdirektori . "/" . $namaAman;
}

echo pathPenyimpanan("3f2a9c8e1d7b4560aabbccddeeff0011.jpg") . "\n";
echo pathPenyimpanan("9d4f0b7c2e8a13579bbaeecffdde2200.jpg") . "\n";

Output yang Dihasilkan:

text
storage/uploads/3f/3f2a9c8e1d7b4560aabbccddeeff0011.jpg
storage/uploads/9d/9d4f0b7c2e8a13579bbaeecffdde2200.jpg

Dua karakter heksadesimal pertama tersebar merata karena namanya acak, sehingga 256 kemungkinan subdirektori terisi seimbang — sejuta berkas terbagi rata hanya sekitar empat ribu berkas per folder. Pada kode nyata, pembuatan subdirektori dengan mkdir($path, 0775, true) diikuti pemeriksaan is_dir() dilakukan sebelum pemindahan berkas, persis seperti pada endpoint proses-upload.php di atas.

Ringkasan #

PHP mengotomatiskan transport upload, lalu menyerahkan keputusan kepada kode Anda melalui $_FILES: periksa error lebih dahulu, validasi ukuran dan jenis berkas berdasarkan isinya, pastikan asal-usulnya dengan is_uploaded_file(), dan pindahkan dengan move_uploaded_file(). Simpan berkas dengan nama acak, ekstensi dari daftar putih, di luar document root, dan sebar ke subdirektori hash bingga jumlahnya besar. Seluruh alur ini berdiri di atas satu abstraksi yang belum kita buka: setiap berkas yang dipindahkan dan dibaca adalah stream — topik yang menjadi Chapter terakhir dari rangkaian konsep dasar ini.

Referensi #