Mengunggah berkas adalah titik tempat dunia luar menyentuh filesystem kita: pengguna mengirim apa pun dari perangkatnya — ukuran bisa raksasa, nama bisa aneh, isinya bisa jadi bukan yang diklaimnya. Laravel menyiapkan alur lengkap untuk aliran ini: form khusus ber-enctype multipart, object UploadedFile di sisi request, dan method store() yang mengurus penamaan sekaligus lokasi penyimpanan. Kita bangun alur itu dari form mentah sampai feature test yang membuktikan berkas benar-benar mendarat di disk.
Form HTML dengan enctype multipart #
Berkas tidak bisa ikut dalam request POST biasa; form harus menyatakan enctype="multipart/form-data" agar browser mengirim berkas sebagai bagian terpisah dari field teks. Blade view berikut — resources/views/dokumen/form.blade.php — juga memakai @csrf karena setiap POST form wajib membawa token CSRF:
<form method="POST" action="/dokumen" enctype="multipart/form-data">
@csrf
<label for="dokumen">Pilih berkas (pdf, png, jpg, maks 2MB)</label>
<input type="file" id="dokumen" name="dokumen" required>
@error('dokumen')
<p>{{ $message }}</p>
@enderror
<button type="submit">Unggah</button>
</form>Tanpa enctype tersebut, $_FILES di sisi server akan kosong dan $request->file('dokumen') mengembalikan null — jebakan klasik yang gejalanya adalah upload yang “selalu gagal tanpa pesan error”.
Dari Request ke Object UploadedFile #
Di sisi controller, berkas hadir sebagai instance Illuminate\Http\UploadedFile. Method hasFile('dokumen') memastikan field ada, dan isValid() memeriksa bahwa pengunggahan HTTP-nya sendiri sukses (kode error upload PHP bernilai UPLOAD_ERR_OK). Sebelum menyimpan, validasi mimes dan max memeriksa isi berkas dan ukurannya:
namespace App\Http\Controllers;
use App\Models\Dokumen;
use Illuminate\Http\Request;
class DokumenController extends Controller
{
public function simpan(Request $request)
{
$request->validate([
'dokumen' => ['required', 'file', 'mimes:pdf,png,jpg', 'max:2048'],
]);
$path = $request->file('dokumen')->store('users/'.$request->user()->id);
Dokumen::create([
'user_id' => $request->user()->id,
'judul' => $request->file('dokumen')->getClientOriginalName(),
'path' => $path,
]);
return back()->with('status', 'Berkas tersimpan di '.$path);
}
}Alur method simpan di atas: validasi menolak berkas yang ekstensi isinya tidak cocok atau melebihi 2 MB, store() menulis berkas ke disk default lalu mengembalikan path relatifnya, dan path itu disimpan ke tabel dokumen agar berkas bisa ditemukan lagi nanti. Pembahasan mendalam tentang perilaku rule mimes ada di chapter Verifikasi File Upload
dan section Validasi dan Form
.
Satu kebiasaan kecil pada rule di atas patut dicermati: nilai max dihitung dalam kilobyte, jadi max:2048 berarti tepat 2 MB — bukan 2048 byte. Nama field input (dokumen) juga sebaiknya konsisten dari form sampai $request->file('dokumen'), karena string yang tidak cocok akan mengembalikan null dan error-nya hanya muncul sebagai pesan validasi umum.
Bila satu form perlu menerima beberapa berkas sekaligus, larik input bernama berkas[] membuat $request->file('berkas') mengembalikan array of UploadedFile — cukup di-loop untuk menyimpan semuanya, dengan rule validasi ditulis memakai notasi berkas.* agar diperiksa per elemen.
store(): Nama Hash Otomatis #
Method store() menghasilkan nama berkas acak 40 karakter dengan ekstensi yang ditebak dari isi berkas, bukan disalin dari nama yang dikirim pengguna. Kita buktikan dengan instance UploadedFile nyata:
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
$tmp = tempnam(sys_get_temp_dir(), 'prop').'.pdf';
file_put_contents($tmp, '%PDF-1.4 isi proposal');
$berkas = new UploadedFile($tmp, 'proposal.pdf', 'application/pdf', null, true);
// store() di disk default: nama hash otomatis
$path1 = $berkas->store('dokumen');
$bagian = pathinfo($path1);
echo 'store() : '.$bagian['dirname'].'/<hash-acak>.'
.$bagian['extension']."\n";
echo 'pola 40 karakter acak: '.var_export(
(bool) preg_match('/^[a-zA-Z0-9]{40}$/', $bagian['filename']),
true
)."\n";
// storeAs(): nama kustom di disk pilihan
$path2 = $berkas->storeAs('dokumen', 'proposal-final.pdf', 'public');
echo 'storeAs() : '.$path2."\n";
echo 'ada di public: '.var_export(Storage::disk('public')->exists($path2), true)."\n";Output yang Dihasilkan:
store() : dokumen/<hash-acak>.pdf
pola 40 karakter acak: true
storeAs() : dokumen/proposal-final.pdf
ada di public: trueTiga pola pemakaian terlihat sekaligus di sini:
store('dokumen')— menyimpan ke disk default di bawah folderdokumen/, dengan nama hash acak. Nilai hash berbeda setiap kali dijalankan, jadi dua pengguna yang mengunggahlaporan.pdftidak akan saling menimpa.store()tanpa argumen — berkas mendarat langsung di root disk (storage/app/privateuntuk disk default).storeAs('dokumen', 'proposal-final.pdf', 'public')— argumen ketiga memilih disk, sehingga berkas tersimpan distorage/app/public/dokumen/proposal-final.pdf. Nama kita tanggung jawab sendiri: nama bentrok akan menimpa berkas lama.
Pengujian dengan UploadedFile::fake() #
Feature test untuk alur upload memakai UploadedFile::fake(). Method createWithContent() membuat berkas palsu berisi data nyata, Storage::fake('local') mengganti disk dengan versi terisolasi, dan assertExists() membuktikan hasil akhirnya:
namespace Tests\Feature;
use App\Models\Dokumen;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class UploadDokumenTest extends TestCase
{
use RefreshDatabase;
private const PDF = "%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF";
public function test_berkas_pdf_tersimpan_dengan_nama_hash(): void
{
Storage::fake('local');
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/dokumen', [
'dokumen' => UploadedFile::fake()->createWithContent('laporan-2024.pdf', self::PDF),
]);
$response->assertRedirect();
$response->assertSessionHas('status');
$dokumen = Dokumen::sole();
$this->assertSame('users/1', dirname($dokumen->path));
$this->assertSame('pdf', pathinfo($dokumen->path, PATHINFO_EXTENSION));
$this->assertSame('laporan-2024.pdf', $dokumen->judul);
Storage::disk('local')->assertExists($dokumen->path);
$this->assertSame(1, preg_match('/^[a-zA-Z0-9]{40}$/', basename($dokumen->path, '.pdf')));
}
public function test_berkas_exe_ditolak_validasi(): void
{
Storage::fake('local');
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/dokumen', [
'dokumen' => UploadedFile::fake()->create('program.exe', 10),
]);
$response->assertSessionHasErrors('dokumen');
$this->assertSame(0, Dokumen::count());
}
}Output yang Dihasilkan:
PASS Tests\Feature\UploadDokumenTest
✓ berkas pdf tersimpan dengan nama hash 0.17s
✓ berkas exe ditolak validasi 0.01s
Tests: 2 passed (10 assertions)
Duration: 0.26sTest pertama memverifikasi seluruh rantai: redirect sukses, baris tersimpan di database, path berada di folder users/1, ekstensi tetap pdf, dan berkas eksis di disk dengan nama 40 karakter acak. Test kedua memastikan berkas .exe sudah ditolak di lapisan validasi sebelum sempat menyentuh disk — tabel dokumen tetap kosong. Detail perilaku mimes saat menolak berkas yang menyamar menjadi fokus chapter berikutnya; rujukan resmi method store() ada di dokumentasi Filesystem
.