File Upload API & Storage Management - Perwira Learning Center

 

 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:

  1. Keamanan - File dari pengguna bisa berbahaya jika tidak divalidasi dengan benar
  2. Efisiensi - File besar perlu dioptimalkan dan distorage dengan bijak
  3. Organisasi - File perlu disimpan dalam struktur yang rapi dan mudah diakses
  4. 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
<?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
bash
# 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:

text
http://localhost:8000/storage/nama-file.jpg

3.2 Persiapan Database untuk File Upload

3.2.1 Migration untuk Tabel dengan Kolom File
bash
# 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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
bash
composer require intervention/image
3.5.2 Konfigurasi Intervention Image
php
<?php
// File: config/image.php

return [
    'driver' => 'gd' // atau 'imagick'
];
3.5.3 Upload dengan Generate Thumbnail
php
<?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
php
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
<?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
<?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
<?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');
php
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
bash
php artisan make:rule FileTypeRule
php
<?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:

php
use App\Rules\FileTypeRule;

$request->validate([
    'document' => ['required', 'file', new FileTypeRule(['pdf', 'doc', 'docx'])]
]);
3.8.2 Validasi Virus/Keamanan (Opsional)
php
// 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
bash
php artisan make:event FileUploaded
php
<?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
bash
php artisan make:listener ProcessUploadedFile
php
<?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
<?php
// File: app/Providers/EventServiceProvider.php

protected $listen = [
    FileUploaded::class => [
        ProcessUploadedFile::class,
    ],
];
3.9.4 Dispatch Event
php
event(new FileUploaded($request->file('image'), $path));

3.10 Testing File Upload dengan Postman

3.10.1 Upload Single File

Request:

text
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:

json
{
    "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:

text
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
javascript
// 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
php
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
<?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.");
    }
}
php
// Register command di Kernel
// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
    $schedule->command('files:cleanup')->daily();
}

3.12 Kendala dan Solusi

KendalaSolusi
File terlalu besarBatasi ukuran dengan validasi max, gunakan chunk upload
Memory limit exhaustedTingkatkan memory limit, proses file dengan streaming
Upload timeoutGunakan queue untuk proses berat, set timeout lebih panjang
File duplikatGenerate unique filename dengan timestamp + random string
File tidak bisa diaksesCek symbolic link, pastikan disk 'public' terkonfigurasi benar
Permission deniedSet permission folder storage: chmod -R 775 storage
Virus/malwareScan file sebelum disimpan, gunakan antivirus package
Storage penuhMonitor 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

Posting Komentar

0 Komentar