File Upload & Storage - Perwira Learning Center

Latar Belakang

Sebagai lanjutan dari pembahasan mengenai pembelajaran Laravel pada minggu lalu, tahap berikutnya adalah mempelajari implementasi file upload. Dalam pengembangan aplikasi web, fitur unggah file sering kali dibutuhkan, seperti untuk mengunggah foto profil, gambar produk, atau dokumen tertentu. Oleh karena itu, pemahaman mengenai cara menangani file di sisi server menjadi bagian penting dalam pengembangan aplikasi.
Artikel ini dibuat dengan tujuan untuk mendokumentasikan hasil pembelajaran saya mengenai proses file upload di Laravel. Dengan memahami mekanisme ini, saya dapat membangun fitur unggah file yang terstruktur, aman, dan sesuai dengan kebutuhan sistem.

Alat dan Bahan

Alat dan Bahan yang digunakan yaitu sebagai berikut:

A. Perangkat Lunak

  • Web browser (Google Chrome) 
  • Code editor (Visual Studio Code)
  • Terminal
  • Web server    
  • Composer 
  • PHP
  • Laravel

B. Perangkat Keras

  • Laptop

Kenapa File Upload Perlu Ditangani dengan Benar?

Bayangkan kamu membuat aplikasi marketplace. Pengguna upload ribuan foto produk. Tanpa manajemen yang baik, dalam beberapa bulan saja storage server bisa penuh dengan file-file yang tidak terpakai. Belum lagi masalah keamanan: pengguna bisa saja meng-upload file berbahaya jika kita tidak melakukan validasi dengan ketat.
File upload menjadi pintu masuk potensial bagi berbagai masalah:
  • Masalah teknis: storage penuh, performa lambat, file corrupt
  • Masalah keamanan: malware upload, directory traversal attacks, file inclusion vulnerabilities
  • Masalah pengalaman pengguna: file gagal upload, proses lambat, file hilang
  • Masalah maintenance: sulit backup, migrasi data rumit, file sampah menumpuk

Risiko Jika Salah Mengelola File

Beberapa risiko yang sering terjadi:

1. Storage Membengkak Tanpa Kendali

Dalam satu aplikasi yang saya maintain, ternyata ada jutaan file temporary hasil upload yang tidak pernah dihapus. Storage 100GB penuh dalam setahun. Setelah ditelusuri, ternyata developer sebelumnya lupa menghapus file saat data dihapus.

2. Konflik Nama File

Dua user upload dengan nama file "foto.jpg" secara bersamaan. File pertama tertimpa file kedua. User pertama komplain fotonya hilang. Ini masalah klasik yang sebenarnya mudah dihindari.

3. Celah Keamanan

Pernah ada kasus terkenal dimana attacker upload file dengan nama "../../../config/database.php" yang mencoba menimpa file konfigurasi. Atau upload file dengan ekstensi .php yang berisi script jahat, lalu mengaksesnya langsung melalui browser.

4. Performa Menurun

Struktur folder yang tidak terorganisir dengan ribuan file dalam satu folder akan memperlambat akses file. Sistem operasi akan kesulitan mengelola file dalam jumlah besar di satu direktori.

5. Kesulitan Migrasi

Saat aplikasi berkembang dan perlu pindah ke cloud storage seperti Amazon S3, aplikasi dengan kode yang mencampur logika penyimpanan akan sangat sulit dimigrasi.

Validasi File Upload

Langkah pertama dan paling krusial adalah validasi. Jangan pernah percaya dengan input dari pengguna, termasuk file yang mereka upload. Prinsip ini harus selalu kita pegang: "never trust user input".

Validasi Tipe File

Laravel menyediakan dua aturan validasi untuk tipe file: mimes dan mimetypes. Aturan mimes lebih sederhana karena kita cukup menyebutkan ekstensi file yang diizinkan.

// Validasi dengan mimes
'foto' => 'required|mimes:jpg,jpeg,png|max:2048'
// Validasi dengan mimetypes (lebih akurat)
'dokumen' => 'required|mimetypes:application/pdf,image/jpeg|max:5120'

Perbedaan keduanya:
  • mimes : hanya mengecek ekstensi file dari nama file. Seseorang bisa dengan mudah mengubah nama virus.exe menjadi virus.jpg dan lolos validasi ini.
  • mimetypes : mengecek MIME type yang sebenarnya dengan membaca isi file. Ini lebih aman karena bisa mendeteksi file yang diubah ekstensinya.
Untuk keamanan maksimal, gunakan mimetypes. Tapi perlu diingat, pengecekan MIME type bergantung pada konfigurasi PHP dan mungkin sedikit lebih lambat.

Validasi Ukuran File

Aturan max digunakan untuk membatasi ukuran file dalam satuan kilobyte. Contoh max:2048 berarti maksimal 2MB
'video' => 'required|mimes:mp4,mov,avi|max:102400' // maksimal 100MB
Perlu diingat bahwa selain validasi di Laravel, kita juga perlu memperhatikan konfigurasi PHP di php.ini:
  • upload_max_filesize - batas maksimal ukuran file upload
  • post_max_size - batas maksimal total data POST (termasuk file)
  • max_execution_time - waktu maksimal eksekusi script

Validasi Lainnya

Laravel juga menyediakan aturan validasi tambahan untuk file:

'gambar' => 'required|file|image|dimensions:min_width=100,min_height=100|max:2048'
'dokumen' => 'nullable|file|mimes:pdf|min:50' // minimal 50KB

  • image - memastikan file adalah gambar (jpg, png, bmp, gif, svg)
  • dimensions - validasi dimensi gambar (lebar, tinggi, rasio)
  • min - ukuran minimal file

Contoh Penggunaan di Controller

Mari kita lihat contoh lengkap validasi di controller dengan pesan error kustom:

<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class ProductController extends Controller
{
    public function store(Request $request)
    {
        // Pesan error untuk validasi field product_image
        $messages = [
            // Validasi jika file tidak diupload
            'product_image.required' => 'Foto produk wajib diupload',
            // Validasi jika format file tidak sesuai ketentuan
            'product_image.mimes' => 'Foto harus berformat JPG, JPEG, atau PNG',
            // Validasi jika ukuran file melebihi 2MB (2048 KB)
            'product_image.max' => 'Ukuran foto maksimal 2MB',
            // Validasi jika dimensi gambar kurang dari 300x300 piksel
            'product_image.dimensions' => 'Foto minimal berukuran 300x300 piksel'
        ];
        // Melakukan validasi terhadap input dari form
        $request->validate([
            
            // Field name:
            // - required: wajib diisi
            // - string: harus berupa teks
            // - max:255: maksimal 255 karakter
            'name' => 'required|string|max:255',
            // Field price:
            // - required: wajib diisi
            // - numeric: harus berupa angka
            // - min:0: tidak boleh bernilai negatif
            'price' => 'required|numeric|min:0',
            // Field product_image:
            // - required: wajib diupload
            // - file: memastikan input berupa file
            // - mimes: membatasi tipe file yang diperbolehkan
            // - max:2048: ukuran maksimal 2MB
            // - dimensions: minimal lebar dan tinggi 300 piksel
            'product_image' => 'required|file|mimes:jpg,jpeg,png|max:2048|dimensions:min_width=300,min_height=300'
        ], $messages);
        // Jika validasi gagal, Laravel otomatis akan:
        // - Mengembalikan ke halaman sebelumnya
        // - Mengirimkan pesan error ke session
        
        // Jika validasi berhasil, maka proses penyimpanan data dapat dilanjutkan
        // Proses penyimpanan file akan dibahas pada bagian berikutnya
    }
}

Penyimpanan File di Storage/App

Setelah validasi berhasil, kita perlu menyimpan file. Laravel memiliki sistem storage yang fleksibel melalui Filesystem Integration. Sistem ini mengabstraksi berbagai driver penyimpanan sehingga kode kita tidak perlu berubah jika suatu saat kita migrasi dari local storage ke cloud storage.

Penggunaan Method store() dan storeAs()

Cara paling mudah menyimpan file adalah dengan method store () atau storeAs() yang disediakan oleh instance UploadedFile.

// Cara 1: store() - nama file di-generate otomatis
$path = $request->file('product_image')->store('product-images');
// Cara 2: storeAs() - kita bisa menentukan nama file sendiri
$path = $request->file('product_image')->storeAs(
    'product-images', 
    'product-' . time() . '.' . $request->file('product_image')->extension()
);
// Cara 3: menyimpan dengan disk tertentu
$path = $request->file('product_image')->store('product-images', 'p    ublic');

Method store() akan mengembalikan path relatif file yang bisa kita simpan ke database. Secara default, file akan disimpan di disk local (folder storage/app).

Disk Storage

Laravel mendukung beberapa disk storage yang didefinisikan di config/filesystems.php. Mari kita lihat konfigurasi default:

// config/filesystems.php
'disks' => [
    'local' => [
        'driver' => 'local',
        'root' => storage_path('app'),
        'throw' => false,
    ],
    'public' => [
        'driver' => 'local',
        'root' => storage_path('app/public'),
        'url' => env('APP_URL').'/storage',
        'visibility' => 'public',
        'throw' => false,
    ],
    's3' => [
        'driver' => 's3',
        'key' => env('AWS_ACCESS_KEY_ID'),
        'secret' => env('AWS_SECRET_ACCESS_KEY'),
        'region' => env('AWS_DEFAULT_REGION'),
        'bucket' => env('AWS_BUCKET'),
        'url' => env('AWS_URL'),
        'endpoint' => env('AWS_ENDPOINT'),
        'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
        'throw' => false,
    ],
],

Penjelasan masing-masing disk:
  • local: Untuk file internal yang tidak perlu diakses publik. Cocok untuk backup, file temporary, atau file yang diproses di background.
  • public: Untuk file yang perlu diakses publik seperti gambar profil, foto produk, dll. File di disk ini bisa diakses melalui symlink.
  • s3: Untuk Amazon S3 atau layanan compatible S3 lainnya (DigitalOcean Spaces, MinIO, dll)

Kenapa File Tidak Disimpan Langsung di Folder Public?

Ini pertanyaan penting! Banyak pemula menyimpan file langsung di public/uploads. Kenapa tidak disarankan?
1. Keamanan
File di folder public bisa diakses langsung oleh siapa saja tanpa melalui kontrol aplikasi. Jika seseorang mengetahui URL file, mereka bisa langsung mengaksesnya. Ini masalah jika kita ingin menerapkan sistem otorisasi untuk file tertentu (misalnya hanya member premium yang bisa download dokumen).
2. Fleksibilitas Storage
Dengan storage system, kita bisa dengan mudah mengganti ke cloud storage tanpa mengubah kode. Bayangkan kita sudah menulis kode seperti ini di 50 tempat berbeda:

$file->move(public_path('uploads'), $filename);

Suatu saat kita ingin pindah ke Amazon S3. Kita harus mengubah semua kode tersebut. Bandingkan jika dari awal kita menggunakan:

$path = $file->store('uploads', 'public');

Untuk pindah ke S3, cukup ubah disk di konfigurasi dari public ke S3, semua kode tetap jalan.
3. Organisasi dan Backup
Dengan memisahkan file upload ke folder storage, kita bisa dengan mudah melakukan backup terpisah. Backup database saja tidak cukup karena file juga penting. Dengan struktur yang rapi, kita bisa backup folder storage/app secara rutin.
4. Testing
Laravel memiliki fitur fake storage untuk testing. Dengan menggunakan storage system, kita bisa dengan mudah melakukan testing upload file tanpa benar-benar menyimpan file.

Membuat Symbolic Link

Oke, kita sepakat untuk menyimpan file yang perlu diakses publik di storage/app/public. Tapi bagaimana cara mengakses file tersebut dari browser? Di sinilah kita butuh symbolic link.

Fungsi php artisan storage:link

Jalankan perintah ini di terminal:

php artisan storage:link

Perintah ini akan membuat folder public/storage yang menjadi symlink ke storage/app/public. Hasilnya kurang lebih seperti ini :

# Di Linux/Mac
ln -s /var/www/project/storage/app/public /var/www/project/public/storage
# Di Windows (menggunakan mklink)
mklink /D C:\project\public\storage C:\project\storage\app\public

Hubungan storage/app/public dan public/storage

Ilustrasinya seperti ini:
File asli: storage/app/public/product-images/foto.jpg
Link: public/storage/product-images/foto.jpg
Jadi, ketika browser mengakses https://domain.com/storage/product-images/foto.jpg, server akan mengarahkan ke file asli di storage/app/public/product-images/foto.jpg.

Kenapa Ini Penting?

Dengan symlink, kita bisa menjaga file tetap aman di folder storage (di luar public) tapi tetap bisa diakses publik melalui URL yang bersih. Jika kita perlu membatasi akses (misalnya hanya user tertentu yang bisa lihat), kita bisa buat route khusus yang melayani file:

// Route untuk mengakses file yang dilindungi

Route::get('/protected-file/{filename}', function ($filename) {
    // Mengecek apakah user sudah login (terautentikasi)
    // Jika belum login, maka akan menampilkan error 403 (Forbidden)
    if (!auth()->check()) {
        abort(403);
    }
    
    // Menentukan path lengkap file di dalam folder storage/app/protected
    // storage_path() akan mengarahkan ke direktori storage Laravel
    $path = storage_path('app/protected/' . $filename);
    
    // Mengecek apakah file benar-benar ada di lokasi tersebut
    // Jika tidak ditemukan, akan menampilkan error 404 (Not Found)
    if (!file_exists($path)) {
        abort(404);
    }
    
    // Mengembalikan file sebagai response agar dapat ditampilkan atau diunduh
    return response()->file($path);
});

Penanganan Nama File agar Tidak Bentrok

Ini masalah klasik: dua user upload file dengan nama yang sama foto.jpg. Yang kedua akan menimpa yang pertama. User pertama komplain fotonya hilang. Solusinya? Buat nama file unik.

Penggunaan hashName()

Method hashName() akan menghasilkan nama file random yang unik menggunakan fungsi hash_file() PHP:

$file = $request->file('product_image');
$path = $file->storeAs('product-images', $file->hashName());

hashName() menghasilkan string seperti 7y8e9wq2e3r4t5y6u7i8o9p0.jpg yang hampir mustahil bentrok. Hash ini dihasilkan dari konten file dan timestamp, sehingga file yang berbeda akan menghasilkan hash berbeda.

Timestamp atau UUID

Alternatif lain, kita bisa gunakan timestamp:

$filename = time() . '_' . $file->getClientOriginalName();
$path = $file->storeAs('product-images', $filename);

Tapi cara ini masih ada risiko bentrok jika dua user upload di detik yang sama. Kita bisa kombinasikan dengan random string:

$filename = time() . '_' . uniqid() . '.' . $file->extension();

Atau gunakan UUID (Universally Unique Identifier):
use Illuminate\Support\Str;

$filename = Str::uuid() . '.' . $file->extension();
$path = $file->storeAs('product-images', $filename);

UUID menjamin keunikan secara global, sangat aman untuk sistem terdistribusi.

Membuat Struktur Folder Berdasarkan Tanggal

Untuk file dalam jumlah besar, sebaiknya tidak menyimpan semua file dalam satu folder. Buat struktur folder berdasarkan tanggal:

$folder = 'product-images/' . date('Y') . '/' . date('m');
$path = $file->store($folder, 'public');

Hasilnya:
product-images/2024/01/foto1.jpg
product-images/2024/01/foto2.jpg
product-images/2024/02/foto3.jpg
Struktur ini memudahkan manajemen dan backup. Kita juga bisa dengan mudah menghapus file lama (misalnya file lebih dari 1 tahun) dengan melihat foldernya.

Menyimpan Informasi Lengkap di Database

Selain path, simpan juga informasi tambahan:

<?php
// Mengambil file dari request dengan nama field 'product_image'
$file = $request->file('product_image');
// Mengambil nama asli file saat diupload oleh user
$originalName = $file->getClientOriginalName();
// Mengambil MIME type file (contoh: image/jpeg, image/png)
$mimeType = $file->getMimeType();
// Mengambil ukuran file dalam satuan byte
$size = $file->getSize();
// Mengambil ekstensi file (jpg, png, dll)
$extension = $file->extension();
// Membuat nama file baru yang unik
// time() → timestamp saat ini
// Str::random(10) → string acak 10 karakter
// Tujuannya untuk menghindari konflik nama file
$filename = time() . '_' . Str::random(10) . '.' . $extension;
// Menyimpan file ke folder storage/app/public/product-images
// 'product-images' → nama folder
// $filename → nama file yang sudah dibuat unik
// 'public' → disk storage yang digunakan (sesuai config/filesystems.php)
$path = $file->storeAs('product-images', $filename, 'public');
// Menyimpan data produk ke database
// termasuk metadata file yang telah diambil sebelumnya
Product::create([
    'name' => $request->name,
    'image_path' => $path, // path relatif file di storage
    'image_original_name' => $originalName, // nama asli file
    'image_mime_type' => $mimeType, // tipe file
    'image_size' => $size, // ukuran file
]);

Informasi ini berguna untuk:
  • Menampilkan ukuran file ke user
  • Validasi tambahan di level database
  • Proses migrasi atau restore jika diperlukan
  • Audit trail

Mengapa Disarankan untuk Tidak Menggunakan Nama Asli File?

Selain masalah bentrok, nama asli file bisa mengandung:
1. Karakter Khusus dan Spasi
Nama file seperti "foto produk 2024/01.jpg" bisa menyebabkan masalah di berbagai sistem operasi. Spasi perlu di-encode di URL, karakter / bisa diinterpretasikan sebagai path.
2. Karakter Berbahaya
Attacker bisa mencoba upload file dengan nama ../../../etc/passwd untuk melakukan path traversal attack. Atau script.php.jpg untuk menipu validasi ekstensi.
3. Encoding Issues
Nama file dengan karakter non-ASCII (seperti bahasa Jepang atau Arab) bisa bermasalah di server dengan encoding berbeda.
4. Panjang Nama
Nama file terlalu panjang bisa melebihi batas maksimum sistem file (255 karakter di banyak sistem).
Solusi terbaik: selalu generate nama file baru, simpan nama asli di database jika diperlukan.

Update & Replace File Lama

Saat user mengupdate data beserta file-nya, kita perlu menghapus file lama agar storage tidak penuh. Proses ini harus dilakukan dengan hati-hati karena jika ada error di tengah jalan, kita bisa kehilangan file lama sebelum file baru berhasil tersimpan.

Alur Update File yang Aman

  1. Validasi input termasuk file baru
  2. Simpan file baru terlebih dahulu (jika ada)
  3. Jika penyimpanan file baru berhasil, hapus file lama
  4. Update database dengan path file baru
  5. Jika ada error, rollback (file baru dihapus, data tidak berubah)

Contoh Implementasi di Controller

public function update(Request $request, $id)
{
    // Mengambil data produk berdasarkan ID
    // Jika tidak ditemukan, otomatis akan menampilkan error 404
    $product = Product::findOrFail($id);
    // Validasi input
    $request->validate([
        // name wajib diisi, string, maksimal 255 karakter
        'name' => 'required|string|max:255',
        // product_image opsional (nullable),
        // tetapi jika diupload harus berupa file jpg/jpeg/png,
        // maksimal 2MB dan minimal lebar 300px
        'product_image' => 'nullable|file|mimes:jpg,jpeg,png|max:2048|dimensions:min_width=300'
    ]);
    // Ambil hanya field yang boleh diupdate
    $data = $request->only('name', 'price', 'description');
    // Cek apakah ada file baru yang diupload
    if ($request->hasFile('product_image')) {
        try {
            // Simpan file baru terlebih dahulu
            // Disimpan ke storage/app/public/product-images
            $newPath = $request->file('product_image')
                ->store('product-images', 'public');
            // Jika penyimpanan berhasil, masukkan path baru ke array update
            $data['image_path'] = $newPath;
            // Setelah file baru aman tersimpan,
            // baru hapus file lama (jika ada)
            if ($product->image_path) {
                // Cek apakah file lama benar-benar ada
                if (Storage::disk('public')->exists($product->image_path)) {
                    // Hapus file lama dari storage
                    Storage::disk('public')->delete($product->image_path);
                }
            }
        } catch (\Exception $e) {
            // Jika terjadi error saat upload,
            // dan file baru sempat tersimpan, maka hapus kembali
            if (isset($newPath) && 
                Storage::disk('public')->exists($newPath)) {
                Storage::disk('public')->delete($newPath);
            }
            // Kembalikan ke halaman sebelumnya dengan pesan error
            return back()->with(
                'error',
                'Gagal mengupload file: ' . $e->getMessage()
            );
        }
    }
    // Update data produk di database
    $product->update($data);
    // Redirect ke halaman index dengan pesan sukses
    return redirect()
        ->route('products.index')
        ->with('success', 'Produk berhasil diupdate');
}

Delete File Saat Record Dihapus

Ini sering terlupakan! Saat data dihapus dari database, file-nya masih bersarang di storage. Seiring waktu, ini akan menjadi sampah digital yang memenuhi server.

Penggunaan Storage::delete()

public function destroy($id)
{
    $product = Product::findOrFail($id);
    
    // Hapus file dari storage
    if ($product->image_path) {
        $fullPath = storage_path('app/public/' . $product->image_path);
        
        if (file_exists($fullPath)) {
            unlink($fullPath);
        }
        
        // Atau menggunakan Storage facade
        // Storage::disk('public')->delete($product->image_path);
    }
    
    // Hapus data dari database
    $product->delete();
    
    return redirect()->route('products.index')
        ->with('success', 'Produk berhasil dihapus');
}

Menggunakan Model Event untuk Otomatisasi

Daripada menulis kode hapus file di setiap controller, kita bisa memanfaatkan model events Laravel:

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class Product extends Model
{
    protected $fillable = ['name', 'price', 'image_path'];
    
    protected static function booted()
    {
        // Hapus file saat model dihapus
        static::deleted(function ($product) {
            if ($product->image_path) {
                Storage::disk('public')->delete($product->image_path);
            }
        });
        
        // Hapus file lama saat diupdate dengan file baru
        static::updating(function ($product) {
            // Cek apakah image_path berubah
            if ($product->isDirty('image_path')) {
                $oldPath = $product->getOriginal('image_path');
                
                if ($oldPath) {
                    Storage::disk('public')->delete($oldPath);
                }
            }
        });
    }
}

Dengan pendekatan ini, kita tidak perlu lagi menulis kode hapus file di controller. Cukup hapus model, file otomatis terhapus.

Optimasi File Upload untuk Produksi

Menggunakan Queue untuk File Besar

Jika aplikasi menerima upload file besar, proses upload bisa membuat pengguna menunggu lama. Pertimbangkan menggunakan queue untuk memprosesnya di background.

app/Http/Controllers/VideoController.php

public function store(Request $request)
{
    // Validasi file video
    // - wajib
    // - format mp4
    // - maksimal 500MB (512000 KB)
    $request->validate([
        'video' => 'required|mimes:mp4|max:512000'
    ]);
    // Simpan file sementara terlebih dahulu
    // agar tidak hilang setelah request selesai
    $tempPath = $request->file('video')->store('temp-videos');
    // Buat record upload di database
    $upload = PendingUpload::create([
        'user_id' => auth()->id(),
        'original_name' => $request->file('video')->getClientOriginalName(),
        'size' => $request->file('video')->getSize(),
        'status' => 'pending'
    ]);
    // Dispatch job ke queue (hanya kirim ID & path string)
    ProcessVideoUpload::dispatch($upload->id, $tempPath);
    // Response langsung dikirim tanpa menunggu proses selesai
    return response()->json([
        'message' => 'Video sedang diproses',
        'upload_id' => $upload->id
    ]);
}

app/Jobs/ProcessVideoUpload.php

use Illuminate\Support\Facades\Storage;
class ProcessVideoUpload implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    protected $uploadId;
    protected $tempPath;
    // Constructor hanya menerima ID dan path file
    public function __construct($uploadId, $tempPath)
    {
        $this->uploadId = $uploadId;
        $this->tempPath = $tempPath;
    }
    public function handle()
    {
        // Ambil ulang data upload dari database
        $upload = PendingUpload::find($this->uploadId);
        if (!$upload) {
            return;
        }
        try {
            // Update status menjadi processing
            $upload->update(['status' => 'processing']);
            // Tentukan path final
            $finalPath = 'videos/' . time() . '_' . $upload->original_name;
            // Pindahkan file dari temp ke folder final
            Storage::move($this->tempPath, $finalPath);
            // Update database setelah berhasil
            $upload->update([
                'status' => 'completed',
                'path' => $finalPath
            ]);
            // Di sini bisa tambahkan:
            // - kirim email
            // - broadcast event
            // - trigger encoding ffmpeg
        } catch (\Exception $e) {
            // Jika gagal, update status menjadi failed
            $upload->update([
                'status' => 'failed'
            ]);
            // Hapus file temp jika masih ada
            if (Storage::exists($this->tempPath)) {
                Storage::delete($this->tempPath);
            }
        }
    }
}

Kompresi dan Optimasi Gambar

Untuk gambar, kita bisa melakukan kompresi otomatis:

use Intervention\Image\Facades\Image;
use Illuminate\Http\UploadedFile;
public function uploadWithOptimization(UploadedFile $file)
{
    // Membuat instance image dari file upload
    // Intervention akan membaca file dan mengubahnya menjadi object yang bisa dimanipulasi
    $image = Image::make($file);
    
    // Cek apakah lebar gambar lebih dari 1200px
    // Jika ya, lakukan resize agar tidak terlalu besar
    if ($image->width() > 1200) {
        
        // Resize lebar menjadi 1200px
        // Tinggi diset null agar mengikuti aspect ratio
        $image->resize(1200, null, function ($constraint) {
            
            // Menjaga rasio asli agar gambar tidak gepeng
            $constraint->aspectRatio();
            
            // Mencegah gambar kecil diperbesar (no upscale)
            $constraint->upsize();
        });
    }
    
    // Membuat nama file baru (format JPG)
    // Menggunakan timestamp agar unik
    $filename = time() . '.jpg';
    
    // Menentukan lokasi penyimpanan file di storage
    $path = storage_path('app/public/product-images/' . $filename);
    
    // Menyimpan gambar dengan kualitas kompresi 80%
    // Nilai 0–100 (semakin kecil, semakin terkompres)
    $image->save($path, 80);
    
    // Mengembalikan path relatif untuk disimpan ke database
    return 'product-images/' . $filename;
}

Multiple File Upload

Menangani multiple file upload:

public function uploadMultiple(Request $request)
{
    // Validasi:
    // - photos wajib ada dan harus berupa array
    // - maksimal 5 file
    // - setiap item dalam photos harus file jpg/jpeg/png
    // - ukuran maksimal 2MB per file
    $request->validate([
        'photos' => 'required|array|max:5',
        'photos.*' => 'required|file|mimes:jpg,jpeg,png|max:2048'
    ]);
    
    // Array untuk menyimpan path file yang berhasil diupload
    $paths = [];
    
    // Loop setiap file yang dikirim
    foreach ($request->file('photos') as $file) {
        
        // Simpan file ke storage/app/public/gallery
        // dan simpan path relatifnya ke dalam array
        $paths[] = $file->store('gallery', 'public');
    }
    
    // Kembalikan response JSON berisi pesan dan daftar path file
    return response()->json([
        'message' => 'Upload berhasil',
        'paths' => $paths
    ]);
}

Chunk Upload untuk File Besar

Untuk file sangat besar (video 4K, dll), implementasikan chunk upload:

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
public function uploadChunk(Request $request)
{
    // Validasi input chunk
    $request->validate([
        'file_id' => 'required|string',
        'chunk' => 'required|file',
        'chunk_index' => 'required|integer|min:0',
        'total_chunks' => 'required|integer|min:1'
    ]);
    $fileId      = $request->file_id;
    $chunkIndex  = $request->chunk_index;
    $totalChunks = $request->total_chunks;
    $chunk       = $request->file('chunk');
    // Folder temporary berdasarkan file_id
    $tempPath = storage_path("app/temp/chunks/{$fileId}");
    // Buat folder jika belum ada
    if (!file_exists($tempPath)) {
        mkdir($tempPath, 0755, true);
    }
    // Simpan chunk sesuai index
    $chunk->move($tempPath, "chunk_{$chunkIndex}.part");
    // Cek apakah semua chunk sudah terkumpul
    $uploadedChunks = glob("{$tempPath}/*.part");
    if (count($uploadedChunks) == $totalChunks) {
        $this->assembleChunks($fileId, $tempPath);
    }
    return response()->json([
        'status' => 'success',
        'message' => 'Chunk diterima'
    ]);
}

private function assembleChunks($fileId, $tempPath)
{
    // Tentukan lokasi file final
    $finalDirectory = storage_path("app/public/uploads");
    if (!file_exists($finalDirectory)) {
        mkdir($finalDirectory, 0755, true);
    }
    $finalPath = "{$finalDirectory}/{$fileId}.mp4";
    // Buka file final dalam mode write binary
    $finalFile = fopen($finalPath, 'wb');
    if (!$finalFile) {
        return;
    }
    // Ambil semua chunk
    $chunks = glob("{$tempPath}/*.part");
    // Urutkan natural agar chunk_1, chunk_2, chunk_10 tidak salah urut
    natsort($chunks);
    // Gabungkan menggunakan stream (hemat memory)
    foreach ($chunks as $chunkPath) {
        $handle = fopen($chunkPath, 'rb');
        if ($handle) {
            while (!feof($handle)) {
                fwrite($finalFile, fread($handle, 1024 * 1024)); // 1MB per baca
            }
            fclose($handle);
        }
    }
    fclose($finalFile);
    // Hapus seluruh folder temporary setelah selesai
    Storage::deleteDirectory("temp/chunks/{$fileId}");
}

Best Practice

Jangan Simpan File Langsung di Public

Selalu gunakan storage system Laravel. Ini memberi kita fleksibilitas dan keamanan lebih baik.

Pisahkan Logika Upload

Buat service class khusus untuk menangani upload. Controller tidak perlu tahu detail penyimpanan.

<?php
namespace App\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
class FileUploadService
{
    /**
     * Upload gambar produk ke storage public
     *
     * @param UploadedFile $file
     * @return string Path relatif file yang tersimpan
     */
    public function uploadProductImage(UploadedFile $file)
    {
        // Membuat nama file unik berdasarkan timestamp
        // Ditambahkan nama asli file untuk keterbacaan
        $filename = time() . '_' . $file->getClientOriginalName();
        // Menyimpan file ke:
        // storage/app/public/product-images
        // dan mengembalikan path relatifnya
        return $file->storeAs('product-images', $filename, 'public');
    }
    
    /**
     * Menghapus file dari storage public
     *
     * @param string|null $path
     * @return void
     */
    public function deleteFile($path)
    {
        // Cek apakah path ada dan file benar-benar tersedia
        if ($path && Storage::disk('public')->exists($path)) {
            
            // Hapus file dari storage
            Storage::disk('public')->delete($path);
        }
    }
}

Cek Keberadaan File Sebelum Menghapus

Selalu cek dengan Storage::exists() sebelum menghapus untuk menghindari error.

if (Storage::disk('public')->exists($oldPath)) {
    Storage::disk('public')->delete($oldPath);
}

Gunakan Queue untuk File Besar

Jika aplikasi menerima upload file besar, proses upload bisa membuat pengguna menunggu lama. Pertimbangkan menggunakan queue untuk memprosesnya di background.

Simpan Informasi File Lengkap di Database

Selain path, simpan juga informasi seperti ukuran file, tipe file, dan nama asli. Ini berguna untuk keperluan audit atau jika suatu saat perlu restore.

Hasil Pembelajaran

Melalui pembelajaran ini, saya memahami bahwa proses file upload di Laravel melibatkan form dengan tipe multipart/form-data, validasi file pada controller, serta penyimpanan file menggunakan sistem storage yang telah disediakan oleh Laravel. File yang diunggah dapat disimpan di folder tertentu, baik secara lokal maupun menggunakan disk penyimpanan yang telah dikonfigurasi.
Saya juga mempelajari pentingnya validasi, seperti membatasi tipe file dan ukuran maksimum, agar keamanan aplikasi tetap terjaga. Selain itu, pengelolaan nama file serta penghapusan file lama saat melakukan pembaruan data menjadi bagian penting dalam menjaga kerapian penyimpanan.

Kesimpulan

Implementasi file upload di Laravel memberikan pemahaman mengenai bagaimana sistem menangani data berbentuk file selain data teks biasa. Dengan memanfaatkan fitur storage dan validasi yang tersedia, proses unggah file dapat dilakukan secara lebih aman dan terstruktur.
Pemahaman ini menjadi bagian penting dalam pengembangan aplikasi yang memerlukan pengelolaan media atau dokumen, serta mendukung pembuatan sistem yang lebih lengkap dan profesional.

Daftar Pustaka

Laravel. (n.d.). File storage. Diakses pada 3 Maret 2026, dari https://laravel.com/docs/filesystem

Laravel. (n.d.). File uploads. Diakses pada 3 Maret 2026, dari https://laravel.com/docs/requests#storing-uploaded-files

Laravel. (n.d.). Validation. Diakses pada 3 Maret 2026, dari https://laravel.com/docs/validation

Laravel. (n.d.). Queues. Diakses pada 3 Maret 2026, dari https://laravel.com/docs/queues

Laravel. (n.d.). Eloquent: Events. Diakses pada 3 Maret 2026, dari https://laravel.com/docs/eloquent#events

Intervention Image. (n.d.). Image manipulation library for PHP. Diakses pada 3 Maret 2026, dari http://image.intervention.io/

Amazon Web Services. (n.d.). Amazon S3 documentation. Diakses pada 3 Maret 2026, dari https://docs.aws.amazon.com/s3/

Niagahoster. (2023). Cara upload file di Laravel dengan aman. Diakses pada 3 Maret 2026, dari https://www.niagahoster.co.id/blog/

Petani Kode. (2022). Tutorial upload file di Laravel. Diakses pada 3 Maret 2026, dari https://www.petanikode.com/