Di bagian sebelumnya kita sudah menguasai middleware dan authorization. Sekarang kita bahas fitur yang hampir selalu ada di aplikasi web modern: file upload. Dari foto profil pengguna, thumbnail artikel, hingga dokumen lampiran — semua membutuhkan penanganan yang benar dari sisi validasi, penyimpanan, keamanan, dan performa. Laravel menyediakan abstraksi filesystem yang sangat elegan yang memudahkan pengelolaan file di berbagai lokasi penyimpanan — local, Amazon S3, Cloudflare R2, atau layanan cloud lainnya — dengan API yang identik.
💡 Ini adalah bagian kesebelas dari seri "Belajar Laravel dari Nol". Di bagian ini kita akan membahas semua aspek pengelolaan file — dari upload sederhana, validasi, penyimpanan di berbagai disk, image processing, hingga best practices untuk production. Pastikan kamu sudah memahami Form dan Validasi (Bagian 8) karena validasi file adalah bagian penting dari pembahasan ini.
Konsep Dasar: Filesystem dan Disk
Laravel mengabstraksikan sistem penyimpanan file melalui konsep "disk" — setiap disk adalah konfigurasi untuk lokasi penyimpanan tertentu dengan driver yang bisa berbeda. Kamu bisa memiliki disk local untuk development, disk public untuk file yang bisa diakses publik, disk s3 untuk Amazon S3, dan seterusnya — semuanya diakses dengan API yang sama sehingga kode tidak perlu berubah saat kamu beralih dari local ke cloud storage.
<?php
// config/filesystems.php — Konfigurasi disk
return [
// Disk default untuk operasi Storage tanpa nama disk
'default' => env('FILESYSTEM_DISK', 'local'),
'disks' => [
// Local: file tersimpan di storage/app/
// Tidak bisa diakses publik via URL
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'throw' => false,
],
// Public: file tersimpan di storage/app/public/
// Bisa diakses via /storage/ URL setelah php artisan storage:link
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL') . '/storage',
'visibility' => 'public',
'throw' => false,
],
// Amazon S3
'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'), // Untuk S3-compatible (R2, MinIO)
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
],
// Cloudflare R2 (S3-compatible, lebih murah dari S3)
'r2' => [
'driver' => 's3',
'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' => false,
],
],
];
# Konfigurasi .env untuk berbagai disk
# ── DISK LOCAL (development) ────────────────────────────
FILESYSTEM_DISK=public
# ── AMAZON S3 ──────────────────────────────────────────
AWS_ACCESS_KEY_ID=your_key_id
AWS_SECRET_ACCESS_KEY=your_secret_key
AWS_DEFAULT_REGION=ap-southeast-1
AWS_BUCKET=your-bucket-name
AWS_URL=https://your-bucket.s3.ap-southeast-1.amazonaws.com
# ── CLOUDFLARE R2 (S3-compatible) ─────────────────────
R2_ACCESS_KEY_ID=your_r2_key
R2_SECRET_ACCESS_KEY=your_r2_secret
R2_BUCKET=your-r2-bucket
R2_ENDPOINT=https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com
R2_URL=https://pub-XXXXX.r2.dev # Public URL bucket (jika aktif)
# Install package AWS SDK untuk S3/R2:
# composer require league/flysystem-aws-s3-v3
# Buat symbolic link public storage:
php artisan storage:link
# public/storage → storage/app/public
Upload File Sederhana
Mari mulai dengan cara paling sederhana menangani file upload — dari form HTML hingga file tersimpan di storage. Ini adalah pola dasar yang akan selalu kamu gunakan sebagai fondasi untuk implementasi yang lebih kompleks.
Form yang menerima file upload wajib memiliki atribut enctype="multipart/form-data" — tanpa ini, file tidak akan dikirim ke server. Ini adalah kesalahan yang paling sering dilupakan pemula saat pertama kali menangani file upload.
Sebelum menyimpan, validasi file yang di-upload — tipe file, ukuran maksimal, dimensi untuk gambar, dan lainnya. Validasi ini mencegah file berbahaya masuk ke server dan memastikan file sesuai ekspektasi aplikasi.
Gunakan Storage::put(), store(), atau storeAs() untuk menyimpan file. Pilihan method tergantung apakah kamu ingin nama file di-generate otomatis atau ditentukan sendiri.
{{-- Form upload yang benar --}}
{{-- enctype="multipart/form-data" WAJIB ada untuk file upload --}}
<form action="{{ route('posts.store') }}" method="POST"
enctype="multipart/form-data">
@csrf
<div class="mb-4">
<label for="thumbnail">Thumbnail Artikel</label>
<input
type="file"
id="thumbnail"
name="thumbnail"
accept="image/jpeg,image/png,image/webp"
class="@error('thumbnail') border-red-500 @enderror"
>
@error('thumbnail')
<p class="text-red-500 text-sm">{{ $message }}</p>
@enderror
</div>
{{-- Multiple file upload --}}
<div class="mb-4">
<label for="attachments">Lampiran (maks 5 file)</label>
<input
type="file"
id="attachments"
name="attachments[]"
multiple
accept=".pdf,.doc,.docx,.jpg,.png"
>
@error('attachments')
<p class="text-red-500 text-sm">{{ $message }}</p>
@enderror
@error('attachments.*')
<p class="text-red-500 text-sm">{{ $message }}</p>
@enderror
</div>
<button type="submit">Simpan</button>
</form>
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class PostController extends Controller
{
public function store(Request $request)
{
// ── VALIDASI FILE ──────────────────────────────
$request->validate([
'title' => 'required|string|max:255',
'thumbnail' => [
'nullable',
'image', // Harus berupa gambar
'mimes:jpg,jpeg,png,webp', // Tipe yang diizinkan
'max:2048', // Maks 2MB (dalam KB)
'dimensions:min_width=400,min_height=200', // Dimensi minimum
],
'attachments' => 'nullable|array|max:5',
'attachments.*' => 'file|mimes:pdf,doc,docx,jpg,png|max:5120',
]);
// ── CARA 1: store() — nama file otomatis (hash unik) ──
if ($request->hasFile('thumbnail')) {
// Simpan ke storage/app/public/thumbnails/
// Nama file: random hash, misal: "abc123def456.jpg"
$path = $request->file('thumbnail')->store('thumbnails', 'public');
// $path = "thumbnails/abc123def456.jpg"
}
// ── CARA 2: storeAs() — tentukan nama sendiri ──────
if ($request->hasFile('thumbnail')) {
$file = $request->file('thumbnail');
$extension = $file->getClientOriginalExtension();
$filename = Str::slug($request->title) . '-' . time() . '.' . $extension;
$path = $file->storeAs('thumbnails', $filename, 'public');
// $path = "thumbnails/belajar-laravel-1234567890.jpg"
}
// ── CARA 3: Storage::put() — kontrol penuh ──────────
if ($request->hasFile('thumbnail')) {
$file = $request->file('thumbnail');
$contents = file_get_contents($file->getRealPath());
$path = 'thumbnails/' . Str::random(40) . '.' . $file->getClientOriginalExtension();
Storage::disk('public')->put($path, $contents);
}
// ── SIMPAN KE DATABASE ─────────────────────────────
$post = Post::create([
'title' => $request->title,
'thumbnail' => $path ?? null,
]);
// ── MULTIPLE FILE UPLOAD ───────────────────────────
if ($request->hasFile('attachments')) {
foreach ($request->file('attachments') as $file) {
$attachmentPath = $file->store('attachments/' . $post->id, 'public');
$post->attachments()->create([
'path' => $attachmentPath,
'original_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
]);
}
}
return redirect()->route('posts.show', $post)
->with('success', 'Post berhasil disimpan!');
}
}
Storage Facade: Operasi File Lengkap
Storage facade adalah pintu masuk utama untuk semua operasi file di Laravel — membaca, menulis, menghapus, menyalin, memindahkan file, serta mengecek keberadaan dan mendapatkan informasi file. Semua operasi ini bekerja identik di semua disk yang dikonfigurasi.
<?php
use Illuminate\Support\Facades\Storage;
// ── MENYIMPAN FILE ─────────────────────────────────────
Storage::disk('public')->put('path/file.txt', 'Konten file');
Storage::disk('public')->put('avatars/user-1.jpg', $imageContent);
Storage::disk('s3')->put('uploads/doc.pdf', $fileContent);
// Prepend / Append
Storage::disk('local')->prepend('log.txt', 'Baris di awal');
Storage::disk('local')->append('log.txt', 'Baris di akhir');
// ── MEMBACA FILE ───────────────────────────────────────
$content = Storage::disk('public')->get('path/file.txt');
$exists = Storage::disk('public')->exists('thumbnails/image.jpg');
$missing = Storage::disk('public')->missing('thumbnails/image.jpg');
$size = Storage::disk('public')->size('thumbnails/image.jpg'); // bytes
$lastMod = Storage::disk('public')->lastModified('thumbnails/image.jpg'); // timestamp
// ── URL FILE ───────────────────────────────────────────
// URL publik untuk mengakses file
$url = Storage::disk('public')->url('thumbnails/image.jpg');
// https://example.com/storage/thumbnails/image.jpg
// Temporary URL (untuk S3/R2 — berlaku sementara)
$tempUrl = Storage::disk('s3')->temporaryUrl(
'private/document.pdf',
now()->addMinutes(30)
);
// ── MENGHAPUS FILE ─────────────────────────────────────
Storage::disk('public')->delete('thumbnails/old-image.jpg');
Storage::disk('public')->delete(['file1.jpg', 'file2.jpg']); // Beberapa sekaligus
// ── MENYALIN DAN MEMINDAHKAN ───────────────────────────
Storage::disk('public')->copy('original.jpg', 'backup/original.jpg');
Storage::disk('public')->move('temp/image.jpg', 'thumbnails/image.jpg');
// ── DIREKTORI ──────────────────────────────────────────
$files = Storage::disk('public')->files('thumbnails'); // File langsung
$allFiles = Storage::disk('public')->allFiles('uploads'); // Termasuk subfolder
$dirs = Storage::disk('public')->directories('uploads'); // Subfolder
Storage::disk('public')->makeDirectory('new-folder'); // Buat folder
Storage::disk('public')->deleteDirectory('temp-uploads'); // Hapus folder + isi
// ── VISIBILITY (public/private) ────────────────────────
Storage::disk('s3')->setVisibility('uploads/image.jpg', 'public');
$visibility = Storage::disk('s3')->getVisibility('uploads/image.jpg');
// 'public' atau 'private'
// ── CEK TANPA DISK (menggunakan default disk) ──────────
Storage::exists('file.txt');
Storage::url('thumbnails/image.jpg');
Storage::delete('old-file.txt');
Menampilkan File yang Di-upload di Blade
Setelah file berhasil disimpan, kamu perlu menampilkannya di view. Ada beberapa cara bergantung pada disk yang digunakan dan apakah file bersifat publik atau privat.
{{-- ── FILE DI DISK PUBLIC ────────────────────────────── --}}
{{-- Cara 1: Helper asset() + path relatif dari public/ --}}
{{-- HANYA untuk file di public/storage setelah storage:link --}}
<img src="{{ asset('storage/' . $post->thumbnail) }}" alt="{{ $post->title }}">
{{-- Cara 2: Storage::url() — lebih fleksibel, bisa ganti disk --}}
<img src="{{ Storage::disk('public')->url($post->thumbnail) }}" alt="{{ $post->title }}">
{{-- Cara 3: Accessor di Model (paling bersih) --}}
{{-- Definisikan thumbnailUrl accessor di Model --}}
<img src="{{ $post->thumbnail_url }}" alt="{{ $post->title }}">
{{-- Dengan fallback jika tidak ada thumbnail --}}
<img
src="{{ $post->thumbnail
? Storage::url($post->thumbnail)
: asset('images/default-thumbnail.jpg') }}"
alt="{{ $post->title }}"
class="w-full h-48 object-cover"
>
{{-- ── ACCESSOR DI MODEL (cara terbaik) ──────────────── --}}
{{--
// app/Models/Post.php
protected function thumbnailUrl(): Attribute
{
return Attribute::make(
get: fn() => $this->thumbnail
? Storage::disk('public')->url($this->thumbnail)
: asset('images/default-thumbnail.jpg')
);
}
--}}
{{-- ── DOWNLOAD LINK UNTUK FILE PRIVAT ────────────────── --}}
<a href="{{ route('attachments.download', $attachment) }}">
{{ $attachment->original_name }}
<span class="text-sm text-gray-400">
({{ number_format($attachment->size / 1024, 1) }} KB)
</span>
</a>
{{-- ── TAMPILKAN DAFTAR ATTACHMENT ──────────────────── --}}
@if ($post->attachments->isNotEmpty())
<div class="attachments-list">
<h3>Lampiran ({{ $post->attachments->count() }})</h3>
<ul>
@foreach ($post->attachments as $attachment)
<li class="flex items-center gap-3">
{{-- Icon berdasarkan tipe file --}}
@if (str_contains($attachment->mime_type, 'pdf'))
📄
@elseif (str_contains($attachment->mime_type, 'image'))
🖼️
@else
📎
@endif
<a href="{{ route('attachments.download', $attachment) }}"
class="hover:underline">
{{ $attachment->original_name }}
</a>
<span class="text-sm text-gray-400">
{{ number_format($attachment->size / 1024, 1) }} KB
</span>
</li>
@endforeach
</ul>
</div>
@endif
Download File Privat dengan Authorization
Tidak semua file boleh diakses publik — dokumen rahasia, file yang hanya boleh diakses pengguna tertentu, atau file yang disimpan di disk private harus melalui Controller agar authorization bisa diterapkan sebelum file dikirim ke browser.
<?php
// routes/web.php
Route::get('/attachments/{attachment}/download', [AttachmentController::class, 'download'])
->name('attachments.download')
->middleware('auth');
// app/Http/Controllers/AttachmentController.php
namespace App\Http\Controllers;
use App\Models\Attachment;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AttachmentController extends Controller
{
public function download(Attachment $attachment): StreamedResponse
{
// Authorization: hanya pemilik atau admin
$this->authorize('download', $attachment);
// Cek apakah file masih ada
if (!Storage::disk('local')->exists($attachment->path)) {
abort(404, 'File tidak ditemukan.');
}
// Download file dengan nama asli
return Storage::disk('local')->download(
$attachment->path,
$attachment->original_name,
['Content-Type' => $attachment->mime_type]
);
}
// Tampilkan file di browser (bukan download)
// Cocok untuk preview PDF atau gambar
public function preview(Attachment $attachment)
{
$this->authorize('view', $attachment);
if (!Storage::disk('local')->exists($attachment->path)) {
abort(404);
}
return response()->file(
Storage::disk('local')->path($attachment->path),
['Content-Type' => $attachment->mime_type]
);
}
}
Image Processing: Resize dan Optimasi Gambar
Menyimpan gambar yang di-upload langsung tanpa processing adalah resep untuk aplikasi yang lambat — pengguna sering meng-upload foto berukuran 5-10MB dari smartphone mereka, dan menampilkannya langsung akan sangat memboroskan bandwidth. Image processing memungkinkan kamu resize, compress, dan menghasilkan berbagai ukuran thumbnail dari satu gambar yang di-upload.
Laravel tidak menyertakan image processing secara bawaan. Package yang paling populer adalah Intervention Image (versi 3.x) yang menyediakan API yang sangat mudah digunakan untuk resize, crop, filter, dan berbagai operasi gambar lainnya.
Setelah upload, proses gambar sebelum disimpan ke storage — resize ke ukuran yang dibutuhkan, compress untuk mengurangi ukuran file, dan simpan dalam format yang optimal (WebP untuk web modern).
Untuk aplikasi yang menampilkan gambar dalam berbagai ukuran (card kecil, halaman detail, og:image untuk SEO), generate semua ukuran yang dibutuhkan saat upload sehingga tidak perlu proses di setiap kali tampil.
# Install Intervention Image v3
composer require intervention/image
# Install driver GD (sudah ada di PHP biasanya)
# atau driver Imagick (lebih powerful, install via apt)
# sudo apt install php-imagick
<?php
namespace App\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\Laravel\Facades\Image;
class ImageUploadService
{
// Konfigurasi ukuran thumbnail
private array $sizes = [
'thumb' => [300, 200], // Card kecil
'medium' => [800, 500], // Halaman detail
'large' => [1200, 630], // OG Image untuk social media
];
/**
* Upload dan proses gambar, kembalikan path yang tersimpan
*/
public function upload(UploadedFile $file, string $folder = 'images'): array
{
$filename = Str::random(40);
$extension = 'webp'; // Selalu simpan sebagai WebP
$paths = [];
// ── Generate setiap ukuran ─────────────────────
foreach ($this->sizes as $sizeName => [$width, $height]) {
$image = Image::read($file->getRealPath());
// Resize dengan crop (fill) — gambar tidak distorsi
$image->cover($width, $height);
// Encode ke WebP dengan kualitas 80%
$encoded = $image->toWebp(80);
$path = "{$folder}/{$sizeName}/{$filename}.{$extension}";
Storage::disk('public')->put($path, $encoded);
$paths[$sizeName] = $path;
}
// ── Simpan juga gambar original ─────────────────
$originalPath = "{$folder}/original/{$filename}.{$extension}";
$original = Image::read($file->getRealPath());
// Hanya resize jika terlalu besar (maks 2000px lebar)
if ($original->width() > 2000) {
$original->scaleDown(width: 2000);
}
Storage::disk('public')->put($originalPath, $original->toWebp(90));
$paths['original'] = $originalPath;
return $paths;
}
/**
* Hapus semua ukuran gambar
*/
public function delete(array $paths): void
{
foreach ($paths as $path) {
if ($path && Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
}
}
}
/**
* Upload avatar — crop ke ukuran persegi
*/
public function uploadAvatar(UploadedFile $file, int $userId): string
{
$image = Image::read($file->getRealPath());
// Crop ke persegi dari tengah, resize ke 200x200
$image->cover(200, 200);
$path = "avatars/{$userId}.webp";
Storage::disk('public')->put($path, $image->toWebp(85));
return $path;
}
}
<?php
// Menggunakan ImageUploadService di Controller
namespace App\Http\Controllers;
use App\Services\ImageUploadService;
use App\Http\Requests\StorePostRequest;
class PostController extends Controller
{
public function __construct(
private readonly ImageUploadService $imageService
) {}
public function store(StorePostRequest $request)
{
$thumbnailPaths = [];
// Upload dan process gambar
if ($request->hasFile('thumbnail')) {
$thumbnailPaths = $this->imageService->upload(
$request->file('thumbnail'),
'thumbnails'
);
}
$post = Post::create([
'title' => $request->title,
'content' => $request->content,
'thumbnail_thumb' => $thumbnailPaths['thumb'] ?? null,
'thumbnail_md' => $thumbnailPaths['medium'] ?? null,
'thumbnail_lg' => $thumbnailPaths['large'] ?? null,
'user_id' => auth()->id(),
]);
return redirect()->route('posts.show', $post);
}
public function update(UpdatePostRequest $request, Post $post)
{
if ($request->hasFile('thumbnail')) {
// Hapus gambar lama
$this->imageService->delete([
$post->thumbnail_thumb,
$post->thumbnail_md,
$post->thumbnail_lg,
]);
// Upload yang baru
$thumbnailPaths = $this->imageService->upload(
$request->file('thumbnail'),
'thumbnails'
);
$post->update([
'thumbnail_thumb' => $thumbnailPaths['thumb'],
'thumbnail_md' => $thumbnailPaths['medium'],
'thumbnail_lg' => $thumbnailPaths['large'],
]);
}
$post->update($request->safe()->except(['thumbnail']));
return redirect()->route('posts.show', $post);
}
}
💡 Proses image resize secara sinkron di Controller bisa membuat request menjadi lambat untuk gambar besar. Solusi yang lebih baik untuk production adalah menjalankan image processing sebagai background job menggunakan Laravel Queue — simpan file original dulu, kemudian proses resize di queue worker secara asinkron. Kita akan membahas Queue di seri lanjutan.
Mengelola File Lama saat Update dan Delete
Salah satu aspek pengelolaan file yang sering diabaikan adalah membersihkan file lama saat record diupdate atau dihapus. File yang tidak pernah dibersihkan akan menumpuk di storage dan memboroskan ruang disk. Berikut pola-pola terbaik untuk memastikan file selalu bersih.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class Post extends Model
{
// ── HAPUS FILE OTOMATIS SAAT MODEL DIHAPUS ────────
// Menggunakan Eloquent Model Events
protected static function booted(): void
{
// Sebelum post dihapus, hapus file-nya
static::deleting(function (Post $post) {
// Jangan hapus jika soft delete — file masih mungkin dibutuhkan
// Hanya hapus saat force delete atau tidak pakai soft delete
if (!$post->isForceDeleting()) {
return; // Skip jika ini soft delete
}
$post->deleteFiles();
});
// Saat post di-force-delete (permanent delete)
static::forceDeleting(function (Post $post) {
$post->deleteFiles();
});
}
/**
* Hapus semua file yang berkaitan dengan post ini
*/
public function deleteFiles(): void
{
$filesToDelete = array_filter([
$this->thumbnail_thumb,
$this->thumbnail_md,
$this->thumbnail_lg,
]);
if (!empty($filesToDelete)) {
Storage::disk('public')->delete($filesToDelete);
}
// Hapus folder attachment post ini
Storage::disk('public')->deleteDirectory("attachments/{$this->id}");
}
}
// ── HAPUS FILE SAAT UPDATE DI CONTROLLER ─────────────
public function update(UpdatePostRequest $request, Post $post)
{
if ($request->hasFile('thumbnail')) {
// Hapus thumbnail lama sebelum simpan yang baru
if ($post->thumbnail_thumb) {
Storage::disk('public')->delete([
$post->thumbnail_thumb,
$post->thumbnail_md,
$post->thumbnail_lg,
]);
}
$paths = $this->imageService->upload($request->file('thumbnail'), 'thumbnails');
$post->update([
'thumbnail_thumb' => $paths['thumb'],
'thumbnail_md' => $paths['medium'],
'thumbnail_lg' => $paths['large'],
]);
}
// Hapus thumbnail jika checkbox "hapus thumbnail" dicentang
if ($request->boolean('remove_thumbnail') && $post->thumbnail_thumb) {
$post->deleteFiles();
$post->update([
'thumbnail_thumb' => null,
'thumbnail_md' => null,
'thumbnail_lg' => null,
]);
}
$post->update($request->safe()->except(['thumbnail', 'remove_thumbnail']));
return redirect()->route('posts.show', $post)
->with('success', 'Post berhasil diperbarui!');
}
Upload ke Cloud Storage (S3 / Cloudflare R2)
Untuk aplikasi production yang serius, menyimpan file di server yang sama dengan aplikasi bukan praktik terbaik — file tidak ter-backup secara otomatis, tidak terdistribusi secara global, dan akan hilang jika server bermasalah. Cloud storage seperti Amazon S3 atau Cloudflare R2 adalah solusi yang jauh lebih robust, scalable, dan cost-effective untuk menyimpan file production.
<?php
// Pindah ke cloud storage semudah mengganti nama disk
use Illuminate\Support\Facades\Storage;
// ── UPLOAD KE S3 / R2 ──────────────────────────────────
if ($request->hasFile('thumbnail')) {
// Ganti 'public' dengan 's3' atau 'r2'
$path = $request->file('thumbnail')->store(
'thumbnails',
's3' // Disk yang digunakan
);
// URL publik file di S3
$url = Storage::disk('s3')->url($path);
// https://your-bucket.s3.amazonaws.com/thumbnails/abc123.jpg
}
// ── KONFIGURASI DISK YANG DIGUNAKAN BERDASARKAN ENV ───
// Sangat berguna untuk switch antara local dan cloud
// config/filesystems.php atau di .env:
// FILESYSTEM_DISK=s3 (production)
// FILESYSTEM_DISK=public (development)
// Di kode, gunakan disk default tanpa hardcode nama:
$path = $request->file('thumbnail')->store('thumbnails');
// Otomatis menggunakan disk yang dikonfigurasi di .env
// ── TEMPORARY URL untuk file privat di S3/R2 ──────────
// File privat tidak punya URL publik permanen
// Gunakan temporary URL yang kedaluwarsa setelah waktu tertentu
$tempUrl = Storage::disk('s3')->temporaryUrl(
$attachment->path,
now()->addMinutes(30), // Berlaku 30 menit
[
'ResponseContentDisposition' => 'attachment; filename="' . $attachment->original_name . '"',
]
);
// ── STREAM FILE BESAR LANGSUNG DARI S3 ─────────────────
// Untuk file besar, stream langsung tanpa download ke server dulu
public function download(Attachment $attachment)
{
$this->authorize('download', $attachment);
return response()->streamDownload(function () use ($attachment) {
echo Storage::disk('s3')->get($attachment->path);
}, $attachment->original_name);
}
Praktik: Service Upload Lengkap
Mari satukan semua konsep yang sudah dipelajari dalam satu Service class yang komprehensif dan siap dipakai di production — menangani upload gambar dengan resize, upload dokumen, manajemen file lama, dan kompatibel dengan berbagai disk storage.
<?php
// app/Services/FileUploadService.php — Service upload lengkap
namespace App\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\Laravel\Facades\Image;
class FileUploadService
{
private string $disk;
public function __construct()
{
// Disk ditentukan dari environment variable
$this->disk = config('filesystems.default', 'public');
}
/**
* Upload gambar dengan resize otomatis
* Kembalikan path file yang tersimpan
*/
public function uploadImage(
UploadedFile $file,
string $folder,
int $maxWidth = 1200,
int $maxHeight = 800,
int $quality = 85
): string {
$image = Image::read($file->getRealPath());
// Hanya resize jika lebih besar dari maksimum
if ($image->width() > $maxWidth || $image->height() > $maxHeight) {
$image->scaleDown($maxWidth, $maxHeight);
}
$filename = $this->generateFilename('webp');
$path = "{$folder}/{$filename}";
Storage::disk($this->disk)->put(
$path,
$image->toWebp($quality)
);
return $path;
}
/**
* Upload avatar — crop ke persegi
*/
public function uploadAvatar(UploadedFile $file, string $identifier): string
{
$image = Image::read($file->getRealPath());
$image->cover(400, 400); // Crop ke persegi 400x400
$path = "avatars/{$identifier}.webp";
Storage::disk($this->disk)->put($path, $image->toWebp(90));
return $path;
}
/**
* Upload dokumen/file biasa tanpa processing
*/
public function uploadDocument(UploadedFile $file, string $folder): string
{
$filename = $this->generateFilename($file->getClientOriginalExtension());
$path = "{$folder}/{$filename}";
Storage::disk($this->disk)->putFileAs(
$folder,
$file,
$filename
);
return $path;
}
/**
* Hapus satu atau beberapa file
*/
public function delete(string|array|null $paths): void
{
if (empty($paths)) return;
$paths = is_array($paths) ? $paths : [$paths];
$paths = array_filter($paths); // Hapus null/empty
Storage::disk($this->disk)->delete($paths);
}
/**
* Dapatkan URL publik file
*/
public function url(string $path): string
{
return Storage::disk($this->disk)->url($path);
}
/**
* Cek apakah file ada
*/
public function exists(string $path): bool
{
return Storage::disk($this->disk)->exists($path);
}
/**
* Generate nama file unik
*/
private function generateFilename(string $extension): string
{
return Str::random(40) . '.' . strtolower($extension);
}
}
<?php
// app/Http/Controllers/ProfileController.php
// Contoh penggunaan FileUploadService di Controller
namespace App\Http\Controllers;
use App\Services\FileUploadService;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
class ProfileController extends Controller
{
public function __construct(
private readonly FileUploadService $fileService
) {}
public function update(Request $request): RedirectResponse
{
$request->validate([
'name' => 'required|string|max:255',
'bio' => 'nullable|string|max:500',
'avatar' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:2048|dimensions:min_width=100',
]);
$user = auth()->user();
$data = $request->only(['name', 'bio']);
if ($request->hasFile('avatar')) {
// Hapus avatar lama
if ($user->avatar) {
$this->fileService->delete($user->avatar);
}
// Upload avatar baru
$data['avatar'] = $this->fileService->uploadAvatar(
$request->file('avatar'),
(string) $user->id
);
}
$user->update($data);
return redirect()->route('profile.edit')
->with('success', 'Profil berhasil diperbarui!');
}
public function destroyAvatar(): RedirectResponse
{
$user = auth()->user();
if ($user->avatar) {
$this->fileService->delete($user->avatar);
$user->update(['avatar' => null]);
}
return redirect()->route('profile.edit')
->with('success', 'Foto profil berhasil dihapus.');
}
}
⚠️ Tiga hal yang JANGAN dilupakan di production: (1) jalankan php artisan storage:link sekali saat pertama deploy agar file di disk public bisa diakses via URL, (2) tambahkan folder storage ke backup rutin karena file upload tidak ikut ter-backup jika hanya backup database, dan (3) atur file permission yang benar di server (biasanya 755 untuk folder dan 644 untuk file) agar web server bisa membaca file yang di-upload.
Langkah Selanjutnya
Luar biasa — kamu sudah menguasai File Upload dan Storage di Laravel secara menyeluruh! Mulai dari konsep filesystem dan disk, upload sederhana, semua operasi Storage facade, menampilkan dan mendownload file, image processing dengan Intervention Image, mengelola file lama saat update dan delete, upload ke cloud storage, hingga membangun Service class yang siap production. Di bagian kedua belas seri ini, kita akan membahas API dengan Laravel Sanctum — cara membangun REST API yang aman untuk dikonsumsi oleh aplikasi mobile atau JavaScript frontend.
🚀 Sebelum lanjut ke Bagian 12, pastikan kamu sudah berlatih: (1) membuat form upload lengkap dengan validasi file dan menampilkan hasilnya, (2) menginstal Intervention Image dan mencoba resize gambar dengan berbagai metode (cover, scaleDown, dll), (3) membuat route download untuk file privat dengan authorization, dan (4) mengkonfigurasi minimal satu disk cloud (S3 atau R2) dan mencoba upload ke sana. Pengelolaan file yang benar adalah fondasi dari aplikasi web yang handal dan hemat storage.