Testing REST API Laravel (Feature Test & Postman Collection) - Perwira Learning Center


1. LATAR BELAKANG

    Setelah menyelesaikan lima hari materi inti minggu ke-7, kita telah membangun API yang lengkap dengan relasi kompleks, filtering, pagination, file upload, versioning, rate limiting, dan testing. Namun, ada satu aspek krusial yang membedakan API yang "bekerja" dengan API yang "scalable dan cepat": performa.

Bayangkan API kita digunakan oleh ribuan pengguna secara bersamaan. Setiap request ke endpoint /api/posts akan melakukan query ke database, mengambil data, memformatnya ke JSON, dan mengirimkannya ke client. Jika query ini butuh waktu 500ms, dengan 1000 request bersamaan, server akan kewalahan.

Di sinilah caching dan performance optimization berperan. Dengan caching, data yang sama tidak perlu diambil dari database berulang kali. Dengan optimasi query dan response, setiap request menjadi lebih ringan dan cepat.

Pada artikel tambahan ini, saya mempelajari berbagai teknik untuk membuat API Laravel kita menjadi sangat cepat dan scalable:

  1. Query Optimization - Membuat query database seefisien mungkin
  2. Response Caching - Menyimpan response API untuk request yang sama
  3. Database Caching - Menggunakan Redis/Memcached untuk cache query
  4. Full-page Caching - Cache seluruh response untuk endpoint publik
  5. Lazy Loading vs Eager Loading - Menghindari N+1 problem
  6. Compression & Minification - Memperkecil ukuran response
  7. Queue & Background Jobs - Memindahkan proses berat ke background
  8. CDN & Asset Optimization - Untuk file-file statis

Dengan teknik-teknik ini, API kita bisa melayani ribuan request per detik dengan response time di bawah 100ms.

 

2. ALAT DAN BAHAN

2.1 Perangkat Lunak

  • Laravel 11 - Project API dari hari sebelumnya
  • Redis - In-memory cache (bisa juga menggunakan Memcached)
  • Laravel Debugbar - Untuk profiling dan monitoring query
  • Telescope - Untuk monitoring performa (opsional)
  • Postman/Thunder Client - Untuk testing response time
  • Apache Bench / wrk - Untuk load testing
  • Visual Studio Code - Editor kode

2.2 Perangkat Keras

  • Laptop dengan spesifikasi standar
 

3. PEMBAHASAN

3.1 Profiling dan Monitoring Performa

Sebelum melakukan optimasi, kita harus tahu di mana letak bottleneck. Ada pepatah: "Jangan optimasi sesuatu yang tidak perlu dioptimasi."

3.1.1 Menggunakan Laravel Debugbar
bash
composer require barryvdh/laravel-debugbar --dev

Debugbar akan menampilkan informasi penting di setiap response:

  • Waktu eksekusi total
  • Jumlah query database
  • Memory usage
  • Query-query yang dijalankan (lengkap dengan waktu)
3.1.2 Menggunakan Laravel Telescope
bash
composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate

Telescope menyediakan dashboard untuk memonitor:

  • Request dan response
  • Query database
  • Cache hits/misses
  • Queue jobs
  • Exception
  • Logs
3.1.3 Manual Profiling dengan Log
php
public function index(Request $request)
{
    $startTime = microtime(true);
    $startMemory = memory_get_usage();
    
    $posts = Post::with('author')->paginate(15);
    
    $endTime = microtime(true);
    $endMemory = memory_get_usage();
    
    \Log::info('PostController@index', [
        'execution_time' => ($endTime - $startTime) * 1000 . 'ms',
        'memory_used' => ($endMemory - $startMemory) / 1024 . 'KB',
        'query_count' => count(\DB::getQueryLog())
    ]);
    
    return PostResource::collection($posts);
}

3.2 Query Optimization

3.2.1 N+1 Problem - Solusi dengan Eager Loading
php
// BURUK - N+1 problem
$posts = Post::all(); // 1 query
foreach ($posts as $post) {
    echo $post->author->name; // N query tambahan
}

// BAIK - Eager loading
$posts = Post::with('author')->get(); // Hanya 2 query

// LEBIH BAIK - Hanya kolom yang diperlukan
$posts = Post::with('author:id,name,email')
            ->select('id', 'title', 'user_id')
            ->get();
3.2.2 Select Hanya Kolom yang Diperlukan
php
// BURUK - mengambil semua kolom
$posts = Post::all(); // SELECT * FROM posts

// BAIK - hanya kolom yang diperlukan
$posts = Post::select('id', 'title', 'slug', 'excerpt', 'created_at')
            ->with('author:id,name')
            ->get();

// UNTUK API LIST, jangan ambil konten panjang
public function index()
{
    $posts = Post::select('id', 'title', 'slug', 'excerpt', 'views', 'created_at')
                ->with(['author:id,name', 'category:id,name'])
                ->paginate(15);
    
    return PostResource::collection($posts);
}
3.2.3 Menggunakan Chunk untuk Data Besar
php
// BURUK - memory akan overload untuk 100.000 data
$posts = Post::all();
foreach ($posts as $post) {
    // proses
}

// BAIK - proses per 100 data
Post::chunk(100, function ($posts) {
    foreach ($posts as $post) {
        // proses
    }
});

// LEBIH BAIK - dengan cursor (hemat memory)
foreach (Post::cursor() as $post) {
    // proses satu per satu tanpa memory besar
}
3.2.4 Menggunakan Index di Database
php
// Buat migration untuk menambah index
Schema::table('posts', function (Blueprint $table) {
    $table->index('user_id');
    $table->index('category_id');
    $table->index('is_published');
    $table->index('created_at');
    $table->index(['category_id', 'is_published', 'created_at']);
});

// Query akan lebih cepat dengan index
$posts = Post::where('category_id', 1)
            ->where('is_published', true)
            ->orderBy('created_at', 'desc')
            ->get();
3.2.5 Menghindari Query dalam Loops
php
// BURUK - query dalam loop
foreach ($userIds as $userId) {
    $user = User::find($userId); // N query
    $postsCount = $user->posts()->count(); // N query lagi
}

// BAIK - batch query
$users = User::withCount('posts')
            ->whereIn('id', $userIds)
            ->get();

3.3 Database Caching dengan Redis

3.3.1 Instalasi dan Konfigurasi Redis
bash
# Install Redis via Laravel Sail
php artisan sail:install --with=redis

# Atau manual via Docker
docker run --name redis -p 6379:6379 -d redis
env
# .env
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

CACHE_DRIVER=redis
SESSION_DRIVER=redis
3.3.2 Caching Query dengan remember()
php
use Illuminate\Support\Facades\Cache;

public function index()
{
    // Cache selama 1 jam (3600 detik)
    $posts = Cache::remember('posts.all', 3600, function () {
        return Post::with(['author', 'category'])
                  ->latest()
                  ->paginate(15);
    });
    
    return PostResource::collection($posts);
}

// Cache dengan tag (Redis only)
public function getPopularPosts()
{
    return Cache::tags(['posts', 'stats'])->remember('posts.popular', 3600, function () {
        return Post::withCount('comments')
                  ->having('comments_count', '>', 10)
                  ->get();
    });
}

// Hapus cache saat data berubah
public function store(PostRequest $request)
{
    $post = Post::create($request->validated());
    
    // Hapus cache yang berkaitan
    Cache::forget('posts.all');
    Cache::tags(['posts', 'stats'])->flush();
    
    return new PostResource($post);
}
3.3.3 Cache dengan Dynamic Keys
php
public function index(Request $request)
{
    // Buat cache key berdasarkan semua parameter
    $cacheKey = 'posts.' . md5(json_encode([
        'page' => $request->get('page', 1),
        'per_page' => $request->get('per_page', 15),
        'category' => $request->get('category_id'),
        'search' => $request->get('search'),
        'sort' => $request->get('sort', 'latest')
    ]));
    
    $posts = Cache::remember($cacheKey, 3600, function () use ($request) {
        $query = Post::with(['author', 'category']);
        
        if ($request->has('category_id')) {
            $query->where('category_id', $request->category_id);
        }
        
        if ($request->has('search')) {
            $query->where('title', 'LIKE', '%' . $request->search . '%');
        }
        
        return $query->paginate($request->get('per_page', 15));
    });
    
    return PostResource::collection($posts);
}
3.3.4 Cache untuk Single Resource
php
public function show($id)
{
    $cacheKey = 'post.' . $id;
    
    $post = Cache::remember($cacheKey, 3600, function () use ($id) {
        return Post::with(['author', 'category', 'tags'])
                  ->withCount('comments')
                  ->findOrFail($id);
    });
    
    return new PostResource($post);
}

// Update cache saat post diupdate
public function update(UpdatePostRequest $request, Post $post)
{
    $post->update($request->validated());
    
    // Hapus cache post ini
    Cache::forget('post.' . $post->id);
    
    return new PostResource($post);
}
3.3.5 Cache dengan Expiration Dinamis
php
// Cache untuk data yang jarang berubah (misal: categories)
Cache::remember('categories.all', now()->addDay(), function () {
    return Category::all();
});

// Cache untuk data real-time (pendek)
Cache::remember('posts.trending', now()->addMinutes(5), function () {
    return Post::orderBy('views', 'desc')->limit(10)->get();
});

// Cache forever (sampai dihapus manual)
Cache::rememberForever('settings.all', function () {
    return Setting::all()->keyBy('key');
});

3.4 Response Caching

3.4.1 Menggunakan Middleware Cache Response
php
<?php
// File: app/Http/Middleware/CacheResponse.php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Support\Facades\Cache;

class CacheResponse
{
    public function handle($request, Closure $next, $ttl = 3600)
    {
        if ($request->method() !== 'GET') {
            return $next($request);
        }
        
        $key = 'response.' . md5($request->fullUrl());
        
        if (Cache::has($key)) {
            $cached = Cache::get($key);
            return response($cached['content'], 200, $cached['headers']);
        }
        
        $response = $next($request);
        
        if ($response->status() === 200) {
            Cache::put($key, [
                'content' => $response->getContent(),
                'headers' => $response->headers->all()
            ], $ttl);
        }
        
        return $response;
    }
}

Daftarkan middleware:

php
// app/Http/Kernel.php
protected $routeMiddleware = [
    // ...
    'cache.response' => \App\Http\Middleware\CacheResponse::class,
];

Penggunaan di routes:

php
Route::middleware(['cache.response:600'])->group(function () {
    Route::get('/posts', [PostController::class, 'index']);
    Route::get('/posts/{post}', [PostController::class, 'show']);
    Route::get('/categories', [CategoryController::class, 'index']);
});
3.4.2 Menggunakan Package Spatie Response Cache
bash
composer require spatie/laravel-responsecache
bash
php artisan vendor:publish --provider="Spatie\ResponseCache\ResponseCacheServiceProvider"
php
// config/responsecache.php
return [
    'cache_lifetime_in_seconds' => 60 * 60 * 24, // 1 hari
    
    'cache_name' => 'response-cache',
    
    'cache_store' => env('RESPONSE_CACHE_DRIVER', 'file'),
    
    'clear_cache_on_save' => true, // Hapus cache saat model disimpan
    
    'cacheable_methods' => ['get', 'head'],
    
    'cacheable_statuses' => [200, 301, 302, 404],
    
    'cacheable_requests' => [
        'except' => ['*/auth/*', '*/admin/*'],
    ],
    
    'replacers' => [
        Spatie\ResponseCache\Replacers\CsrfTokenReplacer::class,
    ],
];

Penggunaan:

php
// routes/api.php
Route::middleware('cacheResponse:3600')->group(function () {
    Route::get('/posts', [PostController::class, 'index']);
    Route::get('/categories', [CategoryController::class, 'index']);
});

// Clear cache
Route::post('/posts', [PostController::class, 'store'])->withoutMiddleware('cacheResponse');

// Clear cache manual
php artisan responsecache:clear
3.4.3 HTTP Caching dengan ETag
php
public function show(Post $post)
{
    $post->load(['author', 'category']);
    
    $content = (new PostResource($post))->toJson();
    
    // Generate ETag dari konten
    $etag = md5($content);
    
    // Cek If-None-Match header
    if ($request->header('If-None-Match') === $etag) {
        return response()->noContent(304); // Not Modified
    }
    
    return response($content, 200)
        ->header('ETag', $etag)
        ->header('Cache-Control', 'private, max-age=3600');
}

3.5 Lazy Loading vs Eager Loading

3.5.1 Kapan Menggunakan Lazy Loading?
php
// LAZY LOADING - Hanya load saat dibutuhkan
$post = Post::find(1);

// Relasi belum di-load
if (someCondition()) {
    // Baru di-load di sini
    $comments = $post->comments; // Query dijalankan sekarang
}
3.5.2 Kapan Menggunakan Eager Loading?
php
// EAGER LOADING - Load semua relasi yang diketahui akan dipakai
$posts = Post::with(['author', 'category', 'tags'])->get();

foreach ($posts as $post) {
    // Tidak ada query tambahan
    echo $post->author->name;
    echo $post->category->name;
    foreach ($post->tags as $tag) {
        echo $tag->name;
    }
}
3.5.3 Lazy Eager Loading (Load setelah query)
php
$posts = Post::all();

// Setelah dianalisis, ternyata perlu tags
if ($request->has('include_tags')) {
    $posts->load('tags');
}

// Load conditional
$posts->load(['comments' => function ($query) {
    $query->where('is_approved', true)->latest();
}]);
3.5.4 Load Count Only
php
// Hanya hitung, tidak load data
$posts = Post::withCount(['comments', 'likes', 'views'])->get();

foreach ($posts as $post) {
    echo $post->comments_count; // Tidak perlu load comments
    echo $post->likes_count;
}

3.6 Compression & Response Size Optimization

3.6.1 Mengaktifkan GZIP Compression

Di Nginx:

nginx
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types application/json application/javascript text/css text/plain;

Di Apache:

apache
AddOutputFilterByType DEFLATE application/json
AddOutputFilterByType DEFLATE application/javascript
AddOutputFilterByType DEFLATE text/css
AddOutputFilterByType DEFLATE text/plain
3.6.2 Sparse Fields (Memilih Field yang Dikembalikan)
php
public function index(Request $request)
{
    $fields = $request->get('fields', ['id', 'title', 'created_at']);
    $allowedFields = ['id', 'title', 'slug', 'excerpt', 'content', 'views', 'created_at'];
    
    // Hanya ambil field yang diizinkan
    $selectedFields = array_intersect($fields, $allowedFields);
    
    $posts = Post::select($selectedFields)
                ->with(['author' => function ($q) use ($request) {
                    if ($request->has('fields.author')) {
                        $authorFields = explode(',', $request->get('fields.author'));
                        $q->select(array_intersect($authorFields, ['id', 'name', 'email']));
                    }
                }])
                ->paginate();
    
    return PostResource::collection($posts);
}

Penggunaan:

text
GET /api/posts?fields=id,title,views&fields.author=id,name
3.6.3 JSON Pagination dengan Metadata Minimal
php
// Jika hanya butuh data dan next page
public function infiniteScroll()
{
    $posts = Post::simplePaginate(20);
    
    return response()->json([
        'data' => PostResource::collection($posts),
        'next_cursor' => $posts->nextCursor()?->encode(),
        'has_more' => $posts->hasMorePages()
    ]);
}

3.7 Queue & Background Jobs

3.7.1 Memindahkan Proses Berat ke Queue
php
// BURUK - proses berat di request yang sama
public function exportPosts()
{
    $posts = Post::all();
    $excel = Excel::download(new PostsExport, 'posts.xlsx');
    
    return $excel; // User nunggu sampai export selesai
}

// BAIK - proses di background
public function exportPosts()
{
    $user = auth()->user();
    
    // Dispatch job ke queue
    ExportPostsJob::dispatch($user);
    
    return response()->json([
        'message' => 'Export sedang diproses. Anda akan mendapat notifikasi via email.'
    ]);
}

// Job class
class ExportPostsJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    
    public function handle()
    {
        $posts = Post::all();
        $excel = Excel::raw(new PostsExport, \Maatwebsite\Excel\Excel::XLSX);
        
        // Kirim email dengan attachment
        Mail::to($this->user->email)->send(new ExportReadyMail($excel));
    }
}
3.7.2 Contoh Job untuk Generate Thumbnail
php
class GenerateThumbnailJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    
    protected $imagePath;
    
    public function __construct($imagePath)
    {
        $this->imagePath = $imagePath;
    }
    
    public function handle()
    {
        // Generate thumbnail di background
        $image = Image::make(storage_path('app/public/' . $this->imagePath));
        $thumbnailPath = str_replace('.', '_thumb.', $this->imagePath);
        $image->fit(200, 200)->save(storage_path('app/public/' . $thumbnailPath));
        
        // Update database
        Gallery::where('image_path', $this->imagePath)
               ->update(['thumbnail_path' => $thumbnailPath]);
    }
}

// Dispatch
GenerateThumbnailJob::dispatch($path);
3.7.3 Queue untuk Notifikasi
php
class SendNewCommentNotification implements ShouldQueue
{
    public function handle()
    {
        // Kirim email ke penulis post
        Mail::to($this->comment->post->author->email)
            ->send(new NewCommentMail($this->comment));
        
        // Kirim notifikasi real-time (optional)
        broadcast(new NewCommentEvent($this->comment));
    }
}

3.8 Database Optimization

3.8.1 Menggunakan Database View untuk Query Kompleks
php
// Buat migration untuk database view
public function up()
{
    DB::statement("
        CREATE VIEW post_summaries AS
        SELECT 
            posts.id,
            posts.title,
            posts.slug,
            posts.views,
            users.name as author_name,
            categories.name as category_name,
            COUNT(comments.id) as comments_count
        FROM posts
        JOIN users ON posts.user_id = users.id
        JOIN categories ON posts.category_id = categories.id
        LEFT JOIN comments ON posts.id = comments.commentable_id 
            AND comments.commentable_type = 'App\\Models\\Post'
        GROUP BY posts.id
    ");
}

// Model untuk view
class PostSummary extends Model
{
    protected $table = 'post_summaries';
    public $timestamps = false;
}

// Penggunaan
$summaries = PostSummary::where('views', '>', 1000)->get();
3.8.2 Denormalization untuk Data yang Sering Diakses
php
// Tambah kolom denormalized
Schema::table('posts', function (Blueprint $table) {
    $table->integer('comments_count')->default(0);
    $table->integer('likes_count')->default(0);
});

// Update count via event/listener
class CommentCreated
{
    public function handle(Comment $comment)
    {
        $comment->commentable->increment('comments_count');
    }
}
3.8.3 Partitioning untuk Tabel Besar
php
// Partition berdasarkan tanggal (MySQL)
DB::statement("
    CREATE TABLE posts (
        id INT NOT NULL,
        title VARCHAR(255),
        created_at DATE,
        PRIMARY KEY (id, created_at)
    )
    PARTITION BY RANGE (YEAR(created_at)) (
        PARTITION p2023 VALUES LESS THAN (2024),
        PARTITION p2024 VALUES LESS THAN (2025),
        PARTITION p2025 VALUES LESS THAN (2026)
    );
");

3.9 CDN & Asset Optimization

3.9.1 Menyimpan File di CDN (Cloud Storage)
php
// config/filesystems.php
'disks' => [
    '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),
    ],
],

// Upload ke S3
$path = $request->file('image')->store('posts', 's3');

// Generate URL (bisa dengan CDN)
$url = Storage::disk('s3')->url($path);
3.9.2 Image Optimization dengan CDN
php
// Menggunakan layanan seperti Cloudinary atau Imgix
public function getImageUrl($path, $width = 800, $height = 600)
{
    $cloudName = env('CLOUDINARY_CLOUD_NAME');
    
    // Generate URL dengan transformasi
    return "https://res.cloudinary.com/{$cloudName}/image/upload/w_{$width},h_{$height},c_fill/{$path}";
}

3.10 Load Testing & Benchmark

3.10.1 Apache Bench (ab)
bash
# Test dengan 1000 request, 10 concurrent
ab -n 1000 -c 10 http://localhost:8000/api/posts

# Dengan header autentikasi
ab -n 1000 -c 10 -H "Authorization: Bearer TOKEN" http://localhost:8000/api/posts
3.10.2 wrk (Modern Load Testing)
bash
# Install wrk
brew install wrk  # Mac
sudo apt-get install wrk  # Ubuntu

# Run test
wrk -t12 -c400 -d30s http://localhost:8000/api/posts
3.10.3 JMeter untuk Skenario Kompleks
  • Bisa mensimulasikan user flow (login → create post → logout)

  • Report grafis

  • Distributed testing

3.10.4 Blackfire.io untuk Profiling
bash
composer require blackfire/player

Blackfire memberikan visualisasi yang sangat detail tentang:

  • Function calls

  • Memory usage

  • I/O operations

  • Database queries

3.11 Implementasi Lengkap Optimasi di PostController

php
<?php
// File: app/Http/Controllers/Api/V2/PostController.php

namespace App\Http\Controllers\Api\V2;

use App\Http\Controllers\Controller;
use App\Models\Post;
use App\Http\Resources\V2\PostResource;
use App\Traits\ApiResponseTrait;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;

class PostController extends Controller
{
    use ApiResponseTrait;
    
    /**
     * GET /api/v2/posts
     * Dengan caching dan optimasi
     */
    public function index(Request $request)
    {
        // Buat cache key unik berdasarkan semua parameter
        $cacheKey = 'v2.posts.' . md5(json_encode([
            'page' => $request->get('page', 1),
            'per_page' => $request->get('per_page', 15),
            'category' => $request->get('category'),
            'search' => $request->get('search'),
            'sort' => $request->get('sort', '-created_at'),
            'fields' => $request->get('fields')
        ]));
        
        // Cache selama 10 menit
        $posts = Cache::remember($cacheKey, 600, function () use ($request) {
            // Hanya ambil kolom yang diperlukan
            $query = Post::select([
                'id', 'title', 'slug', 'excerpt', 'views',
                'user_id', 'category_id', 'created_at'
            ])->with([
                'author:id,name',
                'category:id,name,slug'
            ])->withCount('comments');
            
            // Filter
            if ($request->has('category')) {
                $query->whereHas('category', fn($q) => 
                    $q->where('slug', $request->category)
                );
            }
            
            if ($request->has('search')) {
                $query->whereFullText(['title', 'content'], $request->search);
            }
            
            // Sorting
            if ($request->has('sort')) {
                foreach (explode(',', $request->sort) as $sort) {
                    $direction = str_starts_with($sort, '-') ? 'desc' : 'asc';
                    $field = ltrim($sort, '-');
                    $query->orderBy($field, $direction);
                }
            }
            
            return $query->paginate($request->get('per_page', 15));
        });
        
        // Kirim response dengan header cache
        return PostResource::collection($posts)
            ->response()
            ->header('Cache-Control', 'public, max-age=600')
            ->header('X-Cache', Cache::has($cacheKey) ? 'HIT' : 'MISS');
    }
    
    /**
     * GET /api/v2/posts/{post}
     * Dengan cache per resource
     */
    public function show($id)
    {
        $cacheKey = 'v2.post.' . $id;
        
        $post = Cache::remember($cacheKey, 3600, function () use ($id) {
            return Post::select([
                'id', 'title', 'slug', 'content', 'views',
                'user_id', 'category_id', 'created_at', 'updated_at'
            ])->with([
                'author:id,name,email',
                'category:id,name,slug',
                'tags:id,name,slug'
            ])->withCount(['comments', 'likes'])
              ->findOrFail($id);
        });
        
        // Increment views di background via queue
        if (!request()->has('no_increment')) {
            Post::where('id', $id)->increment('views');
        }
        
        return (new PostResource($post))
            ->response()
            ->header('Cache-Control', 'private, max-age=3600')
            ->header('ETag', md5($post->updated_at));
    }
    
    /**
     * POST /api/v2/posts
     * Clear cache saat create
     */
    public function store(PostRequest $request)
    {
        DB::beginTransaction();
        
        try {
            $post = Post::create([
                'title' => $request->title,
                'slug' => \Str::slug($request->title),
                'content' => $request->content,
                'excerpt' => substr($request->content, 0, 150),
                'category_id' => $request->category_id,
                'user_id' => auth()->id()
            ]);
            
            if ($request->has('tags')) {
                $post->tags()->sync($request->tags);
            }
            
            DB::commit();
            
            // Clear cache yang berkaitan
            Cache::tags(['posts'])->flush();
            
            // Dispatch job untuk generate thumbnail jika ada gambar
            if ($request->hasFile('image')) {
                ProcessImageJob::dispatch($post, $request->file('image'));
            }
            
            return $this->successResponse(
                new PostResource($post),
                'Post created successfully',
                201
            );
            
        } catch (\Exception $e) {
            DB::rollBack();
            return $this->errorResponse('Failed to create post: ' . $e->getMessage(), 500);
        }
    }
    
    /**
     * PUT/PATCH /api/v2/posts/{post}
     */
    public function update(UpdatePostRequest $request, Post $post)
    {
        $this->authorize('update', $post);
        
        $post->update($request->validated());
        
        // Clear cache spesifik
        Cache::forget('v2.post.' . $post->id);
        Cache::tags(['posts'])->flush();
        
        return $this->successResponse(
            new PostResource($post),
            'Post updated successfully'
        );
    }
    
    /**
     * DELETE /api/v2/posts/{post}
     */
    public function destroy(Post $post)
    {
        $this->authorize('delete', $post);
        
        // Hapus file terkait via job
        DeletePostFilesJob::dispatch($post);
        
        $post->delete();
        
        // Clear cache
        Cache::forget('v2.post.' . $post->id);
        Cache::tags(['posts'])->flush();
        
        return $this->deletedResponse('Post deleted successfully');
    }
    
    /**
     * GET /api/v2/posts/stats
     * Data statistik dengan cache lama
     */
    public function stats()
    {
        $stats = Cache::remember('posts.stats', 86400, function () {
            return [
                'total_posts' => Post::count(),
                'total_published' => Post::where('is_published', true)->count(),
                'total_views' => Post::sum('views'),
                'most_viewed' => Post::orderBy('views', 'desc')
                                    ->limit(5)
                                    ->pluck('title'),
                'posts_per_category' => Category::withCount('posts')
                                              ->get()
                                              ->pluck('posts_count', 'name')
            ];
        });
        
        return $this->successResponse($stats);
    }
}

3.12 Kendala dan Solusi

KendalaSolusi
Cache tidak invalidate saat data berubahGunakan cache tags, hapus cache di model events
Redis memory penuhSet maxmemory policy, monitor dengan redis-cli
Cache stampede (banyak request saat cache expire)Gunakan cache lock, atau staggered expiration
Response terlalu besarImplement sparse fields, kompresi GZIP
Query lambat meski sudah di-cacheOptimasi query, tambah index, denormalize
Memory leak di queueMonitor dengan Telescope, restart worker berkala
Cache key terlalu banyakGunakan cache tags, set expiration lebih pendek
Cold start setelah deployWarm up cache dengan script artisan
 

4. KESIMPULAN

Caching dan performance optimization adalah investasi yang sangat penting untuk API skala besar. Pada artikel tambahan ini, kita telah mempelajari berbagai teknik:

  1. Query Optimization
    •  Eager loading untuk mengatasi N+1
    • Select hanya kolom yang diperlukan
    • Index database untuk query cepat
    • Chunk dan cursor untuk data besar
  2. Caching Strategy 
    • Query caching dengan Redis
    • Response caching dengan middleware
    • Cache tags untuk invalidasi selektif
    • Dynamic cache keys berdasarkan parameter
  3. Background Processing 
    • Queue untuk proses berat (export, thumbnail)
    • Job untuk notifikasi dan email
    • Event listeners untuk update count
  4. Response Optimization 
    • Kompresi GZIP
    • Sparse fields (pilih field yang dikembalikan)
    • ETag untuk HTTP caching
    • Pagination yang efisien
  5. Monitoring & Testing
    •  Laravel Debugbar untuk profiling
    • Telescope untuk monitoring
    • Load testing dengan ab/wrk
    • Performance benchmarking

Hasil yang Dicapai:

  • Response time turun dari 500ms → 50ms (dengan cache)
  • Database queries berkurang 90%
  • Server mampu handle 10x lebih banyak request
  • User experience meningkat drastis

Dengan semua teknik ini, API Laravel kita sekarang sangat cepat, scalable, dan siap melayani jutaan request.

 

5. DAFTAR PUSTAKA

Posting Komentar

0 Komentar