Pendahuluan
Hampir setiap aplikasi web membutuhkan fitur upload file — foto profil, dokumen, invoice, gambar produk. Kelihatannya sederhana, tapi banyak developer tersandung di:
- File terlalu besar dan server crash
- Ekstensi berbahaya yang diupload (
.php,.exe) - Gambar tidak di-resize, makan storage berlebihan
- File tersimpan di server lokal, hilang saat deploy atau scale
Di artikel ini, kita akan bangun sistem upload yang aman, scalable, dan bisa beralih dari local ke cloud storage (S3/Cloudflare R2) hanya dengan mengubah satu baris konfigurasi.
Arsitektur Storage di Laravel
Laravel menggunakan abstraksi Filesystem yang memungkinkan kamu ganti penyimpanan (local ↔ cloud) tanpa mengubah kode aplikasi.
Kode kamu
│
▼
Storage Facade (abstraksi)
│
├── disk('local') → storage/app/
├── disk('public') → storage/app/public/ (dapat diakses browser)
├── disk('s3') → Amazon S3
└── disk('r2') → Cloudflare R2
Konfigurasi disk ada di config/filesystems.php.
Langkah 1: Upload ke Local Storage
Form HTML
{{-- resources/views/profile/edit.blade.php --}}
<form action="/profile/photo" method="POST" enctype="multipart/form-data">
@csrf
@method('PATCH')
<div class="mb-4">
<label>Foto Profil</label>
<input type="file" name="photo" accept="image/*" class="border rounded p-2 w-full">
@error('photo')
<p class="text-red-500 text-sm">{{ $message }}</p>
@enderror
</div>
@if(auth()->user()->avatar)
<img src="{{ Storage::url(auth()->user()->avatar) }}"
alt="Foto profil" class="w-20 h-20 rounded-full mb-4">
@endif
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded">
Simpan Foto
</button>
</form>
Controller
<?php
// app/Http/Controllers/ProfileController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class ProfileController extends Controller
{
public function updatePhoto(Request $request)
{
$request->validate([
'photo' => 'required|image|mimes:jpg,jpeg,png,webp|max:2048', // max 2MB
]);
$user = auth()->user();
// Hapus foto lama jika ada
if ($user->avatar) {
Storage::disk('public')->delete($user->avatar);
}
// Simpan foto baru
// store() otomatis generate nama file unik (UUID)
$path = $request->file('photo')->store('avatars', 'public');
$user->update(['avatar' => $path]);
return back()->with('success', 'Foto profil berhasil diperbarui!');
}
}
Buat Symlink untuk Akses Public
# Wajib dijalankan sekali — buat symlink dari public/storage → storage/app/public
php artisan storage:link
Setelah ini, file yang disimpan di storage/app/public/avatars/xxx.jpg bisa diakses via URL https://domain.com/storage/avatars/xxx.jpg.
Langkah 2: Validasi File yang Ketat
// Form Request untuk validasi upload
$request->validate([
// Validasi gambar
'photo' => [
'required',
'image', // Harus berupa gambar (jpg, png, gif, dll)
'mimes:jpg,jpeg,png,webp', // Ekstensi yang diizinkan
'max:2048', // Maksimal 2MB (dalam kilobytes)
'dimensions:min_width=100,min_height=100,max_width=2000,max_height=2000',
],
// Validasi dokumen
'document' => [
'required',
'file',
'mimes:pdf,doc,docx,xlsx',
'max:10240', // Maksimal 10MB
],
]);
Keamanan Tambahan
// Jangan percaya ekstensi dari nama file asli!
// Gunakan hashName() untuk generate nama aman
$path = $request->file('photo')->storeAs(
'avatars',
$request->file('photo')->hashName(), // UUID + ekstensi asli
'public'
);
// Cek MIME type dari isi file (bukan ekstensi)
$mimeType = $request->file('photo')->getMimeType();
// image/jpeg, image/png, application/pdf, dll.
Langkah 3: Resize Gambar dengan Intervention Image
Menyimpan gambar 5MB resolusi tinggi untuk foto profil adalah pemborosan. Resize sebelum menyimpan!
# Intervention Image v3
composer require intervention/image
<?php
// app/Http/Controllers/ProfileController.php
use Intervention\Image\ImageManager;
use Intervention\Image\Drivers\Gd\Driver;
public function updatePhoto(Request $request)
{
$request->validate([
'photo' => 'required|image|mimes:jpg,jpeg,png,webp|max:5120',
]);
$manager = new ImageManager(new Driver());
// Baca gambar yang diupload
$image = $manager->read($request->file('photo'));
// Resize ke 400x400 (cover = crop otomatis)
$image->cover(400, 400);
// Convert ke WebP untuk ukuran lebih kecil
$filename = 'avatars/' . uniqid() . '.webp';
$image->toWebp(80)->save(storage_path('app/public/' . $filename));
// Simpan path ke database
auth()->user()->update(['avatar' => $filename]);
return back()->with('success', 'Foto berhasil diperbarui!');
}
Buat Thumbnail
// Simpan dua versi: full size dan thumbnail
$baseName = uniqid();
// Foto asli (600x600)
$manager->read($request->file('photo'))
->cover(600, 600)
->toWebp(85)
->save(storage_path("app/public/avatars/{$baseName}.webp"));
// Thumbnail (100x100)
$manager->read($request->file('photo'))
->cover(100, 100)
->toWebp(70)
->save(storage_path("app/public/avatars/{$baseName}_thumb.webp"));
auth()->user()->update([
'avatar' => "avatars/{$baseName}.webp",
'avatar_thumbnail' => "avatars/{$baseName}_thumb.webp",
]);
Langkah 4: Upload ke Amazon S3
Ketika aplikasi scale atau berjalan di multiple server, local storage tidak memadai. Pindah ke S3 tanpa ubah kode!
Setup AWS
- Login ke AWS Console
- Buat S3 Bucket (contoh:
kamusngoding-uploads) - Buat IAM User dengan policy
AmazonS3FullAccess - Catat Access Key ID dan Secret Access Key
# Install flysystem S3 driver
composer require league/flysystem-aws-s3-v3
# .env
AWS_ACCESS_KEY_ID=AKIAXXXXXXXXXXXXXXXX
AWS_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
AWS_DEFAULT_REGION=ap-southeast-1
AWS_BUCKET=kamusngoding-uploads
AWS_URL=https://kamusngoding-uploads.s3.ap-southeast-1.amazonaws.com
Kode Tidak Berubah — Hanya Ganti Disk!
// Sebelumnya menyimpan ke 'public' disk (local)
$path = $request->file('photo')->store('avatars', 'public');
// Ganti ke 's3' disk — kode lainnya SAMA PERSIS
$path = $request->file('photo')->store('avatars', 's3');
// Ambil URL file di S3
$url = Storage::disk('s3')->url($path);
// → https://kamusngoding-uploads.s3.ap-southeast-1.amazonaws.com/avatars/xxx.jpg
Langkah 5: Upload ke Cloudflare R2 (Lebih Murah!)
Cloudflare R2 adalah S3-compatible storage dengan biaya egress gratis (tidak bayar saat file didownload). Sangat cocok untuk file yang sering diakses.
Perbandingan Biaya
| Storage | Penyimpanan | Egress (Download) |
|---|---|---|
| Amazon S3 | $0.023/GB | $0.09/GB |
| Cloudflare R2 | $0.015/GB | GRATIS ✅ |
| Wasabi | $0.0059/GB | Gratis 1:1 rasio |
Setup Cloudflare R2
- Buka Cloudflare Dashboard → R2
- Buat bucket (contoh:
kamusngoding-uploads) - Manage R2 API Tokens → Create Token → Permission: Edit
- Catat Access Key ID, Secret Key, dan endpoint URL
# .env
R2_ACCESS_KEY_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
R2_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
R2_BUCKET=kamusngoding-uploads
R2_ENDPOINT=https://xxxxxxxxxxxx.r2.cloudflarestorage.com
R2_URL=https://uploads.domain-kamu.com # Custom domain opsional
// config/filesystems.php
'disks' => [
// Tambahkan disk r2
'r2' => [
'driver' => 's3', // Kompatibel dengan S3 driver!
'key' => env('R2_ACCESS_KEY_ID'),
'secret' => env('R2_SECRET_ACCESS_KEY'),
'region' => 'auto',
'bucket' => env('R2_BUCKET'),
'url' => env('R2_URL'),
'endpoint' => env('R2_ENDPOINT'),
'use_path_style_endpoint' => true,
],
],
// Upload ke R2 — sama seperti S3, hanya beda nama disk
$path = $request->file('photo')->store('avatars', 'r2');
Langkah 6: Signed URL untuk Akses Private
Untuk file yang tidak boleh diakses sembarangan (dokumen user, invoice, file premium):
// Buat signed URL yang expired setelah 30 menit
$signedUrl = Storage::disk('s3')->temporaryUrl(
$path,
now()->addMinutes(30)
);
// Kembalikan URL ke frontend
return response()->json(['download_url' => $signedUrl]);
// Di Blade
<a href="{{ Storage::disk('s3')->temporaryUrl($document->path, now()->addHour()) }}">
Download Invoice
</a>
URL ini hanya valid 30 menit — setelah itu expired. User harus request ulang.
Langkah 7: Hapus File Saat Data Dihapus
<?php
// app/Observers/UserObserver.php
namespace App\Observers;
use App\Models\User;
use Illuminate\Support\Facades\Storage;
class UserObserver
{
public function deleted(User $user): void
{
// Hapus avatar saat user dihapus
if ($user->avatar) {
Storage::disk('public')->delete($user->avatar);
}
}
}
// app/Providers/AppServiceProvider.php
User::observe(UserObserver::class);
Troubleshooting: Error yang Sering Muncul
The uploaded file exceeds the upload_max_filesize
Penyebab: Batas upload di PHP lebih kecil dari ukuran file.
Solusi:
# Edit php.ini
sudo nano /etc/php/8.3/fpm/php.ini
# Ubah nilai berikut
upload_max_filesize = 20M
post_max_size = 25M # Harus lebih besar dari upload_max_filesize
memory_limit = 256M
# Restart PHP-FPM
sudo systemctl restart php8.3-fpm
Untuk Nginx, tambahkan di config:
client_max_body_size 25M;
Storage link tidak bekerja / 403 Forbidden
Penyebab: Symlink belum dibuat, atau permission salah.
Solusi:
# Hapus symlink lama dan buat ulang
rm public/storage
php artisan storage:link
# Perbaiki permission
sudo chown -R www-data:www-data storage/app/public
sudo chmod -R 775 storage/app/public
S3: Access Denied / InvalidAccessKeyId
Penyebab: IAM permission kurang, atau key salah.
Solusi:
# Test koneksi S3 langsung
php artisan tinker
>>> Storage::disk('s3')->put('test.txt', 'hello');
>>> Storage::disk('s3')->exists('test.txt');
# Jika gagal, cek:
# 1. Key ID dan Secret di .env
# 2. Region harus sesuai dengan bucket
# 3. IAM user sudah punya permission s3:PutObject, s3:GetObject
Gambar corrupt setelah resize
Penyebab: Memory limit PHP tidak cukup untuk memproses gambar besar.
Solusi:
// Naikan memory limit di script
ini_set('memory_limit', '256M');
// Atau di php.ini
memory_limit = 256M
// Alternatif: gunakan driver Imagick (lebih efisien dari GD)
use Intervention\Image\Drivers\Imagick\Driver;
$manager = new ImageManager(new Driver());
// Pastikan Imagick terinstall
sudo apt install php8.3-imagick
Pertanyaan yang Sering Diajukan
Local storage vs S3, kapan harus beralih ke cloud?
Beralih ke cloud storage ketika: (1) kamu butuh multiple server (load balancing), (2) storage lokal mulai penuh dan susah di-scale, (3) butuh CDN untuk file yang diakses dari seluruh Indonesia. Untuk proyek kecil-menengah, local storage + backup reguler sudah cukup.
Cloudflare R2 vs Amazon S3, apa perbedaan harganya?
R2 mengenakan biaya penyimpanan 35% lebih murah dari S3, dan tidak ada biaya egress (bandwidth keluar). Untuk aplikasi dengan banyak download (image-heavy app), R2 bisa hemat biaya 60–80% dibanding S3. Sisi negatifnya, ekosistem R2 tidak selengkap S3.
Bagaimana cara membatasi ukuran upload di Nginx?
Tambahkan client_max_body_size 20M; di block server atau location di konfigurasi Nginx. Nilai ini harus lebih besar dari post_max_size di php.ini agar pesan error yang tepat muncul dari PHP, bukan dari Nginx.
Apakah aman menyimpan file di folder public?
File di storage/app/public (symlink ke public/storage) bisa diakses siapa saja yang tahu URL-nya. Untuk file sensitif, simpan di storage/app/private dan gunakan Signed URL atau controller download dengan auth check. Jangan pernah simpan invoice, kontrak, atau data pribadi di folder public.
Kesimpulan
Upload file yang benar bukan hanya soal “bisa tersimpan” — tapi tentang keamanan, performa, dan skalabilitas:
- Validasi ketat — jangan percaya user, selalu periksa MIME type
- Resize gambar — hemat storage dan bandwidth
- Cloud storage — siap scale tanpa effort
- Signed URL — kontrol akses file sensitif
Dengan Cloudflare R2, kamu bisa hemat biaya storage secara signifikan dibanding S3 — dan karena kompatibel S3, tidak ada kode yang perlu diubah!
Selanjutnya, pelajari cara mensikapi file upload di Testing Laravel — kita akan mock Storage facade agar test tidak benar-benar menyimpan file ke disk.
Selamat — aplikasimu sekarang siap menangani upload file seperti aplikasi enterprise! ☁️