1. LATAR BELAKANG
Setelah mempelajari relasi database dan teknik filtering/pagination, API kita semakin canggih. Namun, aplikasi modern hampir selalu membutuhkan kemampuan untuk mengelola file—mulai dari upload avatar pengguna, gambar artikel, dokumen, hingga video. Tanpa kemampuan ini, API kita akan terbatas hanya pada data tekstual.
Pada hari ketiga minggu ini, saya mempelajari bagaimana Laravel menangani file upload dan manajemen storage. Fitur ini krusial karena:
- Keamanan - File dari pengguna bisa berbahaya jika tidak divalidasi dengan benar
- Efisiensi - File besar perlu dioptimalkan dan distorage dengan bijak
- Organisasi - File perlu disimpan dalam struktur yang rapi dan mudah diakses
- Skalabilitas - Sistem storage harus bisa berkembang seiring bertambahnya file
Laravel menyediakan sistem storage yang powerful melalui Laravel Filesystem yang berbasiskan Flysystem. Dengan ini, kita bisa dengan mudah beralih antara storage lokal, FTP, Amazon S3, atau Google Cloud Storage tanpa mengubah kode.
2. ALAT DAN BAHAN
2.1 Perangkat Lunak
- Laravel 11 - Project API dari hari sebelumnya
- Database - MySQL untuk menyimpan referensi file
- Postman/Thunder Client - Untuk testing upload file
- Visual Studio Code - Editor kode
- Git Bash/Terminal - Untuk menjalankan perintah Artisan
- Intervention Image - Untuk manipulasi gambar (opsional)
2.2 Perangkat Keras
- Laptop dengan spesifikasi standar
3. PEMBAHASAN
3.1 Konsep Dasar Storage di Laravel
Laravel menggunakan konsep "disks" untuk merepresentasikan berbagai tempat penyimpanan. Konfigurasi storage ada di config/filesystems.php.
3.1.1 Konfigurasi Filesystem
<?php // File: config/filesystems.php return [ 'default' => env('FILESYSTEM_DISK', 'local'), '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, ], // Disk kustom untuk upload gambar 'images' => [ 'driver' => 'local', 'root' => storage_path('app/public/images'), 'url' => env('APP_URL').'/storage/images', 'visibility' => 'public', ], // Disk untuk dokumen (private) 'documents' => [ 'driver' => 'local', 'root' => storage_path('app/private/documents'), 'visibility' => 'private', ], ], 'links' => [ public_path('storage') => storage_path('app/public'), ], ];
3.1.2 Membuat Symbolic Link
# Membuat symbolic link dari public/storage ke storage/app/public php artisan storage:link
Setelah menjalankan perintah ini, file yang diupload ke disk public bisa diakses melalui URL:
http://localhost:8000/storage/nama-file.jpg3.2 Persiapan Database untuk File Upload
3.2.1 Migration untuk Tabel dengan Kolom File
# Migration untuk tabel posts (tambah kolom image) php artisan make:migration add_image_to_posts_table # Migration untuk tabel profiles (tambah kolom avatar) php artisan make:migration add_avatar_to_profiles_table # Migration untuk tabel galleries php artisan make:migration create_galleries_table
<?php // database/migrations/xxxx_add_image_to_posts_table.php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { Schema::table('posts', function (Blueprint $table) { $table->string('featured_image')->nullable()->after('content'); $table->string('image_thumbnail')->nullable(); $table->string('image_medium')->nullable(); $table->string('image_large')->nullable(); }); } public function down(): void { Schema::table('posts', function (Blueprint $table) { $table->dropColumn(['featured_image', 'image_thumbnail', 'image_medium', 'image_large']); }); } };
<?php // database/migrations/xxxx_create_galleries_table.php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { Schema::create('galleries', function (Blueprint $table) { $table->id(); $table->foreignId('post_id')->constrained()->onDelete('cascade'); $table->string('image_path'); $table->string('thumbnail_path')->nullable(); $table->string('caption')->nullable(); $table->integer('order')->default(0); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('galleries'); } };
3.2.2 Model untuk Gallery
<?php // File: app/Models/Gallery.php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Factories\HasFactory; class Gallery extends Model { use HasFactory; protected $fillable = [ 'post_id', 'image_path', 'thumbnail_path', 'caption', 'order' ]; protected $appends = ['image_url', 'thumbnail_url']; /** * Relasi ke Post */ public function post() { return $this->belongsTo(Post::class); } /** * Accessor untuk URL gambar */ public function getImageUrlAttribute() { return $this->image_path ? asset('storage/' . $this->image_path) : null; } /** * Accessor untuk URL thumbnail */ public function getThumbnailUrlAttribute() { return $this->thumbnail_path ? asset('storage/' . $this->thumbnail_path) : $this->image_url; } }
3.3 Single File Upload
3.3.1 Upload Avatar User
<?php // File: app/Http/Controllers/Api/ProfileController.php namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Http\Resources\UserResource; use App\Traits\ApiResponseTrait; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; class ProfileController extends Controller { use ApiResponseTrait; /** * POST /api/user/avatar * Upload avatar user */ public function uploadAvatar(Request $request) { // Validasi file $request->validate([ 'avatar' => 'required|image|mimes:jpeg,png,jpg,gif|max:2048', // max 2MB 'crop_data' => 'nullable|json' // untuk crop image dari frontend ]); $user = $request->user(); // Pastikan user punya profile if (!$user->profile) { $user->profile()->create([]); } // Hapus avatar lama jika ada if ($user->profile->avatar) { Storage::disk('public')->delete($user->profile->avatar); } // Generate nama file unik $extension = $request->file('avatar')->getClientOriginalExtension(); $fileName = 'avatar-' . $user->id . '-' . time() . '.' . $extension; // Simpan file $path = $request->file('avatar')->storeAs('avatars', $fileName, 'public'); // Update profile dengan path file $user->profile()->update([ 'avatar' => $path ]); // Load relasi profile $user->load('profile'); return $this->successResponse( new UserResource($user), 'Avatar berhasil diupload', 201 ); } /** * DELETE /api/user/avatar * Hapus avatar */ public function deleteAvatar(Request $request) { $user = $request->user(); if (!$user->profile || !$user->profile->avatar) { return $this->notFoundResponse('Avatar tidak ditemukan'); } // Hapus file Storage::disk('public')->delete($user->profile->avatar); // Update database $user->profile()->update(['avatar' => null]); return $this->successResponse(null, 'Avatar berhasil dihapus'); } }
3.3.2 Request Upload dengan Validasi Kustom
<?php // File: app/Http/Requests/UploadAvatarRequest.php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class UploadAvatarRequest extends FormRequest { public function authorize(): bool { return true; } public function rules(): array { return [ 'avatar' => [ 'required', 'file', 'image', 'mimes:jpeg,png,jpg,gif', 'max:2048', function ($attribute, $value, $fail) { // Validasi dimensi minimum $image = getimagesize($value); $width = $image[0]; $height = $image[1]; if ($width < 100 || $height < 100) { $fail('Ukuran gambar minimal 100x100 pixel.'); } if ($width > 5000 || $height > 5000) { $fail('Ukuran gambar maksimal 5000x5000 pixel.'); } }, ], 'crop_data' => 'nullable|json' ]; } public function messages(): array { return [ 'avatar.required' => 'File avatar wajib diupload', 'avatar.image' => 'File harus berupa gambar', 'avatar.mimes' => 'Format gambar harus jpeg, png, jpg, atau gif', 'avatar.max' => 'Ukuran gambar maksimal 2MB' ]; } }
3.4 Multiple File Upload
3.4.1 Upload Gallery untuk Post
<?php // File: app/Http/Controllers/Api/GalleryController.php namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Gallery; use App\Models\Post; use App\Http\Resources\GalleryResource; use App\Traits\ApiResponseTrait; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\DB; class GalleryController extends Controller { use ApiResponseTrait; /** * POST /api/posts/{post}/gallery * Upload multiple gambar ke gallery post */ public function upload(Request $request, Post $post) { // Cek kepemilikan if ($post->user_id !== $request->user()->id) { return $this->errorResponse('Anda tidak berhak mengupload ke post ini', 403); } // Validasi $request->validate([ 'images' => 'required|array|max:10', 'images.*' => 'required|image|mimes:jpeg,png,jpg|max:5120', // max 5MB per file 'captions' => 'nullable|array', 'captions.*' => 'nullable|string|max:255' ]); $uploaded = []; $errors = []; DB::beginTransaction(); try { foreach ($request->file('images') as $index => $image) { // Generate nama file unik $fileName = 'post-' . $post->id . '-gallery-' . time() . '-' . $index . '.' . $image->getClientOriginalExtension(); // Simpan file $path = $image->storeAs('gallery/' . $post->id, $fileName, 'public'); // Buat record di database $gallery = Gallery::create([ 'post_id' => $post->id, 'image_path' => $path, 'caption' => $request->captions[$index] ?? null, 'order' => $post->gallery()->count() + $index + 1 ]); $uploaded[] = $gallery; } DB::commit(); return $this->successResponse( GalleryResource::collection($uploaded), count($uploaded) . ' gambar berhasil diupload', 201 ); } catch (\Exception $e) { DB::rollBack(); // Hapus file yang sudah terupload foreach ($uploaded as $gallery) { Storage::disk('public')->delete($gallery->image_path); } return $this->errorResponse('Gagal upload: ' . $e->getMessage(), 500); } } /** * POST /api/gallery/reorder * Mengurutkan ulang gambar gallery */ public function reorder(Request $request) { $request->validate([ 'orders' => 'required|array', 'orders.*.id' => 'required|exists:galleries,id', 'orders.*.order' => 'required|integer|min:0' ]); foreach ($request->orders as $item) { Gallery::where('id', $item['id']) ->where('post_id', function($query) use ($request) { $query->select('id') ->from('posts') ->where('user_id', $request->user()->id); }) ->update(['order' => $item['order']]); } return $this->successResponse(null, 'Urutan gallery berhasil diupdate'); } /** * DELETE /api/gallery/{gallery} * Hapus gambar dari gallery */ public function destroy(Request $request, Gallery $gallery) { // Cek kepemilikan melalui post if ($gallery->post->user_id !== $request->user()->id) { return $this->errorResponse('Anda tidak berhak menghapus gambar ini', 403); } // Hapus file Storage::disk('public')->delete($gallery->image_path); if ($gallery->thumbnail_path) { Storage::disk('public')->delete($gallery->thumbnail_path); } // Hapus record $gallery->delete(); return $this->deletedResponse('Gambar berhasil dihapus'); } }
3.4.2 Resource untuk Gallery
<?php // File: app/Http/Resources/GalleryResource.php namespace App\Http\Resources; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; class GalleryResource extends JsonResource { public function toArray(Request $request): array { return [ 'id' => $this->id, 'image_url' => $this->image_url, 'thumbnail_url' => $this->thumbnail_url, 'caption' => $this->caption, 'order' => $this->order, 'created_at' => $this->created_at->format('d M Y H:i'), 'size' => $this->when($this->image_path, function() { return Storage::disk('public')->size($this->image_path); }), 'post' => [ 'id' => $this->post->id, 'title' => $this->post->title ] ]; } }
3.5 Manipulasi Gambar dengan Intervention Image
Untuk menghasilkan thumbnail dan memanipulasi gambar, kita bisa menggunakan package Intervention Image.
3.5.1 Instalasi Intervention Image
composer require intervention/image3.5.2 Konfigurasi Intervention Image
<?php // File: config/image.php return [ 'driver' => 'gd' // atau 'imagick' ];
3.5.3 Upload dengan Generate Thumbnail
<?php // File: app/Http/Controllers/Api/PostController.php use Intervention\Image\Facades\Image; public function uploadFeaturedImage(Request $request, Post $post) { $request->validate([ 'image' => 'required|image|mimes:jpeg,png,jpg|max:5120' ]); $image = $request->file('image'); // Generate nama file $fileName = 'post-' . $post->id . '-featured-' . time() . '.' . $image->getClientOriginalExtension(); // Path untuk menyimpan $path = 'posts/' . $post->id . '/' . $fileName; $thumbnailPath = 'posts/' . $post->id . '/thumb-' . $fileName; $mediumPath = 'posts/' . $post->id . '/medium-' . $fileName; // Simpan original $image->storeAs('public', $path); // Buat thumbnail (150x150) menggunakan Intervention $thumbnail = Image::make($image)->fit(150, 150); $thumbnail->save(storage_path('app/public/' . $thumbnailPath)); // Buat ukuran medium (800x600) $medium = Image::make($image)->resize(800, 600, function ($constraint) { $constraint->aspectRatio(); $constraint->upsize(); }); $medium->save(storage_path('app/public/' . $mediumPath)); // Update post dengan multiple image sizes $post->update([ 'featured_image' => $path, 'image_thumbnail' => $thumbnailPath, 'image_medium' => $mediumPath, 'image_large' => $path // original sebagai large ]); return $this->successResponse([ 'original' => asset('storage/' . $path), 'thumbnail' => asset('storage/' . $thumbnailPath), 'medium' => asset('storage/' . $mediumPath) ], 'Gambar berhasil diupload dengan thumbnail'); }
3.5.4 Watermark Otomatis
public function uploadWithWatermark(Request $request) { $request->validate([ 'image' => 'required|image' ]); $image = $request->file('image'); $img = Image::make($image); // Tambah watermark $watermark = Image::make(public_path('images/watermark.png')) ->resize(200, null, function ($constraint) { $constraint->aspectRatio(); }) ->opacity(50); $img->insert($watermark, 'bottom-right', 10, 10); // Simpan $fileName = 'watermarked-' . time() . '.' . $image->getClientOriginalExtension(); $path = 'watermarked/' . $fileName; $img->save(storage_path('app/public/' . $path)); return $this->successResponse([ 'url' => asset('storage/' . $path) ]); }
3.6 File Upload untuk Dokumen (Private)
Untuk file yang bersifat private (tidak bisa diakses publik), kita gunakan disk local atau disk private.
3.6.1 Upload Dokumen Private
<?php // File: app/Http/Controllers/Api/DocumentController.php namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Document; use App\Traits\ApiResponseTrait; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; class DocumentController extends Controller { use ApiResponseTrait; /** * POST /api/documents * Upload dokumen private */ public function store(Request $request) { $request->validate([ 'title' => 'required|string|max:255', 'document' => 'required|file|mimes:pdf,doc,docx,xls,xlsx|max:10240', // max 10MB 'is_public' => 'boolean' ]); $file = $request->file('document'); $fileName = 'doc-' . time() . '-' . $request->user()->id . '.' . $file->getClientOriginalExtension(); // Simpan di private disk $path = $file->storeAs('documents/' . $request->user()->id, $fileName, 'local'); $document = Document::create([ 'user_id' => $request->user()->id, 'title' => $request->title, 'file_path' => $path, 'file_name' => $file->getClientOriginalName(), 'file_size' => $file->getSize(), 'mime_type' => $file->getMimeType(), 'is_public' => $request->is_public ?? false ]); return $this->successResponse([ 'id' => $document->id, 'title' => $document->title, 'file_name' => $document->file_name, 'file_size' => $this->formatBytes($document->file_size) ], 'Dokumen berhasil diupload', 201); } /** * GET /api/documents/{document}/download * Download dokumen (dengan autorisasi) */ public function download(Request $request, Document $document) { // Cek akses if ($document->user_id !== $request->user()->id && !$document->is_public) { return $this->errorResponse('Anda tidak berhak mengakses dokumen ini', 403); } if (!Storage::disk('local')->exists($document->file_path)) { return $this->notFoundResponse('File tidak ditemukan'); } return Storage::disk('local')->download($document->file_path, $document->file_name); } private function formatBytes($bytes, $precision = 2) { $units = ['B', 'KB', 'MB', 'GB', 'TB']; $bytes = max($bytes, 0); $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); $pow = min($pow, count($units) - 1); $bytes /= pow(1024, $pow); return round($bytes, $precision) . ' ' . $units[$pow]; } }
3.6.2 Model Document
<?php // File: app/Models/Document.php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Factories\HasFactory; class Document extends Model { use HasFactory; protected $fillable = [ 'user_id', 'title', 'file_path', 'file_name', 'file_size', 'mime_type', 'is_public' ]; protected $casts = [ 'is_public' => 'boolean', 'file_size' => 'integer' ]; public function user() { return $this->belongsTo(User::class); } /** * Generate download URL dengan signed route */ public function getDownloadUrlAttribute() { return $this->is_public ? route('documents.download', $this->id) : route('documents.download.secure', $this->id); } }
3.7 Signed URLs untuk Download Aman
Untuk file private yang perlu diakses sementara, kita bisa menggunakan signed URLs.
<?php // File: routes/api.php use App\Http\Controllers\Api\DocumentController; // Route dengan signed URL Route::get('/documents/{document}/secure-download', [DocumentController::class, 'secureDownload']) ->name('documents.download.secure') ->middleware('signed');
public function secureDownload(Document $document) { // URL ini sudah diverifikasi oleh middleware 'signed' if (!Storage::disk('local')->exists($document->file_path)) { return $this->notFoundResponse('File tidak ditemukan'); } return Storage::disk('local')->download($document->file_path, $document->file_name); } // Membuat signed URL (bisa diberikan ke frontend) public function generateDownloadLink(Request $request, Document $document) { if ($document->user_id !== $request->user()->id) { return $this->errorResponse('Unauthorized', 403); } // URL berlaku 1 jam $url = URL::temporarySignedRoute( 'documents.download.secure', now()->addHour(), ['document' => $document->id] ); return $this->successResponse([ 'download_url' => $url, 'expires_at' => now()->addHour()->toDateTimeString() ]); }
3.8 Validasi File Lanjutan
3.8.1 Validasi dengan Rule Kustom
php artisan make:rule FileTypeRule<?php // File: app/Rules/FileTypeRule.php namespace App\Rules; use Closure; use Illuminate\Contracts\Validation\ValidationRule; class FileTypeRule implements ValidationRule { protected $allowedTypes; public function __construct(array $allowedTypes) { $this->allowedTypes = $allowedTypes; } public function validate(string $attribute, mixed $value, Closure $fail): void { $mime = $value->getMimeType(); $extension = $value->getClientOriginalExtension(); if (!in_array($mime, $this->allowedTypes) && !in_array($extension, $this->allowedTypes)) { $fail("Tipe file tidak diizinkan. Gunakan: " . implode(', ', $this->allowedTypes)); } // Cek apakah file benar-benar gambar (bukan hanya ekstensi palsu) if (str_starts_with($mime, 'image/')) { if (!@getimagesize($value)) { $fail('File bukan gambar yang valid.'); } } } }
Penggunaan:
use App\Rules\FileTypeRule; $request->validate([ 'document' => ['required', 'file', new FileTypeRule(['pdf', 'doc', 'docx'])] ]);
3.8.2 Validasi Virus/Keamanan (Opsional)
// Dengan package seperti laravel-antivirus use Jackiedo\LaravelAntivirus\Rules\Antivirus; $request->validate([ 'file' => ['required', 'file', new Antivirus()] ]);
3.9 Event dan Listener untuk File Upload
Kita bisa menggunakan event untuk memproses file setelah upload.
3.9.1 Membuat Event
php artisan make:event FileUploaded<?php // File: app/Events/FileUploaded.php namespace App\Events; use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PresenceChannel; use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; class FileUploaded { use Dispatchable, InteractsWithSockets, SerializesModels; public $file; public $path; public function __construct($file, $path) { $this->file = $file; $this->path = $path; } }
3.9.2 Membuat Listener
php artisan make:listener ProcessUploadedFile<?php // File: app/Listeners/ProcessUploadedFile.php namespace App\Listeners; use App\Events\FileUploaded; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; use Intervention\Image\Facades\Image; class ProcessUploadedFile implements ShouldQueue { public function handle(FileUploaded $event) { // Proses file di background $fullPath = storage_path('app/public/' . $event->path); if (str_starts_with($event->file->getMimeType(), 'image/')) { // Buat thumbnail $img = Image::make($fullPath); $thumbnailPath = str_replace('.', '_thumb.', $event->path); $img->fit(200, 200)->save(storage_path('app/public/' . $thumbnailPath)); // Log atau update database \Log::info('Thumbnail created: ' . $thumbnailPath); } } }
3.9.3 Daftarkan Event & Listener
<?php // File: app/Providers/EventServiceProvider.php protected $listen = [ FileUploaded::class => [ ProcessUploadedFile::class, ], ];
3.9.4 Dispatch Event
event(new FileUploaded($request->file('image'), $path));3.10 Testing File Upload dengan Postman
3.10.1 Upload Single File
Request:
POST {{base_url}}/api/user/avatar Authorization: Bearer {{token}} Content-Type: multipart/form-data Body -> form-data: - Key: avatar (type: File) - Value: [pilih file gambar]
Response:
{ "success": true, "message": "Avatar berhasil diupload", "data": { "id": 1, "name": "John Doe", "avatar": "http://localhost:8000/storage/avatars/avatar-1-1234567890.jpg" } }
3.10.2 Upload Multiple Files
Request:
POST {{base_url}}/api/posts/51/gallery Authorization: Bearer {{token}} Content-Type: multipart/form-data Body -> form-data: - Key: images[] (type: File) [pilih file 1] - Key: images[] (type: File) [pilih file 2] - Key: images[] (type: File) [pilih file 3] - Key: captions[0] (type: Text) -> "Gambar pertama" - Key: captions[1] (type: Text) -> "Gambar kedua"
3.10.3 Testing dengan Pre-request Script
// Pre-request Script di Postman // Generate random file untuk testing const fs = require('fs'); const path = require('path'); // Buat file dummy const dummyImagePath = path.join(__dirname, 'dummy.jpg'); if (!fs.existsSync(dummyImagePath)) { // Create a simple dummy image const { createCanvas } = require('canvas'); const canvas = createCanvas(100, 100); const ctx = canvas.getContext('2d'); ctx.fillStyle = 'blue'; ctx.fillRect(0, 0, 100, 100); const buffer = canvas.toBuffer('image/jpeg'); fs.writeFileSync(dummyImagePath, buffer); }
3.11 Optimasi Storage
3.11.1 Kompresi Gambar Otomatis
use Intervention\Image\Facades\Image; public function uploadOptimized(Request $request) { $image = $request->file('image'); $img = Image::make($image); // Kompresi dengan kualitas 75% $img->resize(1200, null, function ($constraint) { $constraint->aspectRatio(); $constraint->upsize(); }); $img->save(storage_path('app/public/optimized.jpg'), 75); }
3.11.2 Hapus File yang Tidak Digunakan (Cron Job)
<?php // File: app/Console/Commands/CleanupUnusedFiles.php namespace App\Console\Commands; use Illuminate\Console\Command; use Illuminate\Support\Facades\Storage; use App\Models\Post; use App\Models\Gallery; class CleanupUnusedFiles extends Command { protected $signature = 'files:cleanup'; protected $description = 'Hapus file yang tidak terpakai di database'; public function handle() { // Ambil semua path file yang ada di database $usedFiles = Post::whereNotNull('featured_image') ->pluck('featured_image') ->merge(Gallery::pluck('image_path')) ->toArray(); // Scan semua file di storage $allFiles = Storage::disk('public')->allFiles(); $deleted = 0; foreach ($allFiles as $file) { if (!in_array($file, $usedFiles) && !str_contains($file, '.gitignore')) { Storage::disk('public')->delete($file); $deleted++; $this->info("Deleted: $file"); } } $this->info("Total $deleted unused files deleted."); } }
// Register command di Kernel // app/Console/Kernel.php protected function schedule(Schedule $schedule) { $schedule->command('files:cleanup')->daily(); }
3.12 Kendala dan Solusi
| Kendala | Solusi |
|---|---|
| File terlalu besar | Batasi ukuran dengan validasi max, gunakan chunk upload |
| Memory limit exhausted | Tingkatkan memory limit, proses file dengan streaming |
| Upload timeout | Gunakan queue untuk proses berat, set timeout lebih panjang |
| File duplikat | Generate unique filename dengan timestamp + random string |
| File tidak bisa diakses | Cek symbolic link, pastikan disk 'public' terkonfigurasi benar |
| Permission denied | Set permission folder storage: chmod -R 775 storage |
| Virus/malware | Scan file sebelum disimpan, gunakan antivirus package |
| Storage penuh | Monitor disk usage, cleanup berkala, gunakan cloud storage |
4. KESIMPULAN
File upload dan storage management adalah fitur esensial dalam API modern. Laravel menyediakan tools yang komprehensif untuk menangani berbagai skenario:
1. Konsep Storage
- Multiple disks (local, public, s3)
- Symbolic link untuk akses publik
- Konfigurasi fleksibel
2. Single File Upload
- Validasi tipe dan ukuran file
- Generate unique filename
- Hapus file lama saat update
3. Multiple File Upload
- Array validation
- Transaction dengan rollback
- Gallery management
4. Manipulasi Gambar
- Intervention Image untuk resize, crop, thumbnail
- Multiple image sizes untuk performa
- Watermark otomatis
5. File Private
- Disk private untuk dokumen sensitif
- Signed URLs untuk akses sementara
- Download dengan autorisasi
6. Optimasi & Maintenance
- Kompresi otomatis
- Event untuk background processing
- Cleanup cron job
Dengan kemampuan ini, API kita sekarang bisa menangani berbagai jenis file dengan aman dan efisien. Pada hari keempat, kita akan mempelajari API Versioning & Rate Limiting untuk membuat API kita lebih profesional dan scalable.
5. DAFTAR PUSTAKA
- Laravel Official Documentation. (n.d.). Laravel 12.x Documentation - Filesystem. https://laravel.com/docs/12.x/filesystem
- Laravel Official Documentation. (n.d.). Laravel 12.x Documentation - Requests: File Uploads. https://laravel.com/docs/12.x/requests#files
- Intervention Image. (n.d.). Intervention Image Documentation. https://image.intervention.io
- Laravel Official Documentation. (n.d.). Laravel 12.x Documentation - URL Generation: Signed URLs. https://laravel.com/docs/12.x/urls#signed-urls

0 Komentar