Menjadi developer Laravel yang dibayar mahal bukan soal berapa lama pengalaman coding, tapi tentang skill apa yang dikuasai. Artikel ini membahas 10 teknik advanced Laravel yang wajib dikuasai untuk naik level ke developer profesional bergaji tinggi, mulai dari Rate Limiting untuk melindungi API dari serangan, Role-Based Access Control untuk sistem permission enterprise, Raw SQL Query untuk optimasi performa, hingga Testing untuk membangun kepercayaan dengan automated tests. Skill-skill ini adalah pembeda utama saat technical interview dan alasan client mau membayar rate premium.
Halo teman-teman developer! Saya Angga Risky Setiawan, founder dari BuildWithAngga. Selama bertahun-tahun mengajar dan berinteraksi dengan ribuan developer Indonesia, saya melihat satu pola yang sangat menarik. Ada developer yang sudah coding selama 5 tahun tapi gajinya stuck di angka yang sama. Di sisi lain, ada developer yang baru 2 tahun terjun ke industri tapi sudah dipercaya handle project besar dengan bayaran yang sangat menggiurkan.
Apa yang membedakan mereka? Bukan seberapa lama mereka coding. Bukan juga seberapa banyak project yang sudah dikerjakan. Perbedaannya terletak pada kualitas skill yang mereka kuasai.
Saya sering menemukan developer Laravel yang terjebak di zona nyaman. Mereka sudah mahir membuat CRUD, paham authentication bawaan Laravel, bisa menggunakan Eloquent untuk query sederhana, dan mampu membangun aplikasi yang berfungsi. Tapi ketika ditanya tentang bagaimana melindungi API dari serangan brute force, bagaimana merancang sistem permission yang fleksibel untuk enterprise application, atau bagaimana mengoptimasi query yang lambat pada database dengan jutaan record, mereka kebingungan.
Masalahnya adalah skill-skill dasar itu memang cukup untuk membuat aplikasi yang works. Tapi tidak cukup untuk membuat aplikasi yang production-ready, scalable, dan secure. Dan di situlah perbedaan antara developer yang dibayar standar dengan developer yang dibayar premium.
Perusahaan-perusahaan besar dan client dengan budget tinggi mencari developer yang bisa lebih dari sekadar membuat fitur berfungsi. Mereka mencari developer yang paham bagaimana membangun sistem yang tidak mudah jebol ketika diserang. Developer yang bisa merancang arsitektur yang mudah di-maintain ketika tim berkembang. Developer yang bisa mengidentifikasi dan memperbaiki bottleneck performa sebelum user mulai komplain.
Dalam artikel ini, saya akan membagikan 10 skill yang menurut pengalaman saya menjadi pembeda utama. Skill-skill ini adalah yang sering ditanyakan saat technical interview di perusahaan top. Skill-skill ini juga yang membuat client mau membayar rate lebih tinggi karena mereka tahu aplikasi yang dibangun akan robust dan profesional.
Skill pertama yang akan kita bahas adalah Rate Limiting. Ini adalah teknik untuk melindungi aplikasi dari abuse seperti brute force attack pada form login, spam request ke API, atau bahkan serangan DDoS sederhana. Developer yang paham rate limiting bisa membangun API yang tidak mudah dibobol dan tetap stabil meskipun ada pihak yang mencoba menyerang.
Skill kedua adalah Role-Based Access Control atau RBAC. Hampir semua aplikasi enterprise membutuhkan sistem permission yang kompleks. Ada Super Admin yang bisa melakukan apapun, Admin yang punya akses terbatas, Manager yang hanya bisa melihat data timnya, Staff yang hanya bisa input data, dan seterusnya. Developer yang menguasai RBAC bisa merancang sistem yang fleksibel dan mudah dikembangkan tanpa harus rewrite kode setiap kali ada role baru.
Skill ketiga adalah kemampuan menulis Raw SQL Query. Eloquent memang sangat memudahkan, tapi ada situasi di mana Raw SQL memberikan performa yang jauh lebih baik. Terutama untuk reporting kompleks, aggregasi data dari multiple tabel, atau operasi bulk pada jutaan record. Developer yang hanya bergantung pada Eloquent akan kesulitan ketika menghadapi skenario seperti ini.
Skill keempat adalah Query Optimization secara umum. Ini mencakup pemahaman tentang N+1 problem yang sangat umum di Laravel, teknik eager loading yang tepat, penggunaan chunking untuk data besar, dan kemampuan menganalisis query menggunakan EXPLAIN. Developer yang menguasai optimasi query bisa membuat aplikasi yang tetap cepat meskipun datanya sudah jutaan baris.
Skill kelima adalah Job Queue dan Background Processing. Ketika aplikasi perlu mengirim email ke ribuan user, generate report PDF yang besar, atau sync data ke third-party service, proses ini tidak boleh dilakukan secara synchronous karena akan membuat user menunggu terlalu lama. Developer yang paham queue bisa membangun sistem yang responsif dengan memproses task berat di background.
Skill keenam adalah Event dan Listener untuk arsitektur yang loosely coupled. Bayangkan ketika user register, aplikasi harus mengirim welcome email, membuat default settings, notify admin, dan track analytics. Tanpa event-driven architecture, semua logic ini akan menumpuk di satu controller yang susah di-maintain dan di-test. Developer yang menguasai events bisa membangun sistem yang modular dan mudah dikembangkan.
Skill ketujuh adalah kemampuan membuat Custom Artisan Command. Developer yang bisa membuat automation tools adalah asset besar untuk tim. Command bisa digunakan untuk task seperti data migration, cleanup data lama, generate report berkala, atau system health check. Ditambah dengan scheduling, command-command ini bisa berjalan otomatis tanpa perlu manual trigger.
Skill kedelapan adalah API Versioning. Ketika API sudah digunakan oleh mobile app yang tidak bisa dipaksa update atau third-party integrations, perubahan struktur API bisa menjadi mimpi buruk. Developer yang paham versioning bisa membuat perubahan tanpa breaking existing clients, menunjukkan maturity dalam API design.
Skill kesembilan adalah Exception Handling yang proper. Saya sering menemukan aplikasi yang menampilkan error message seperti "SQLSTATE[42S22]: Column not found" langsung ke user. Ini bukan hanya bad user experience tapi juga security risk karena expose internal structure. Developer profesional tahu cara menangani error dengan elegan dan informatif tanpa membocorkan informasi sensitif.
Skill kesepuluh adalah Testing. Ini mungkin skill yang paling sering diabaikan tapi paling dihargai oleh perusahaan serius. Developer yang menulis test menunjukkan bahwa mereka peduli dengan kualitas dan maintainability jangka panjang. Perusahaan top bahkan tidak akan merge code tanpa test yang proper.
Sepuluh skill ini mungkin terdengar banyak dan overwhelming. Tapi kabar baiknya adalah kalian tidak perlu menguasai semuanya sekaligus. Di artikel-artikel selanjutnya, saya akan membahas setiap skill secara mendalam dengan contoh kode yang bisa langsung diterapkan. Kita akan mulai dari Rate Limiting yang merupakan fondasi untuk membangun API yang secure.
Yang perlu kalian pahami sekarang adalah bahwa setiap skill yang kalian tambahkan ke arsenal adalah investasi untuk karir jangka panjang. Setiap skill membuka pintu ke opportunity yang lebih baik, project yang lebih menantang, dan tentu saja kompensasi yang lebih tinggi.
Jadi siapkan laptop dan kopi kalian. Perjalanan untuk menjadi developer Laravel yang dibayar mahal dimulai dari sini.
Bagian 2: Rate Limiting - Melindungi Aplikasi dari Abuse dan Serangan
Rate Limiting adalah teknik membatasi jumlah request yang bisa dilakukan user dalam periode waktu tertentu untuk melindungi aplikasi dari brute force attack, spam, dan DDoS. Skill ini sangat dihargai di industri karena API tanpa rate limiting adalah target empuk untuk serangan yang bisa membuat server down dan perusahaan rugi besar. Laravel menyediakan RateLimiter facade yang powerful untuk implementasi berbagai strategi limiting berdasarkan IP, user ID, atau kombinasi keduanya.
Saya pernah bekerja dengan sebuah startup fintech yang API payment gateway-nya diserang dengan ribuan request per detik. Tanpa rate limiting, server mereka collapse dan transaksi gagal semua. Kerugiannya? Ratusan juta dalam hitungan jam, belum termasuk hilangnya kepercayaan customer. Sejak kejadian itu, mereka sangat selektif mencari developer dan salah satu syarat utamanya adalah pemahaman mendalam tentang API security termasuk rate limiting.
Konsep rate limiting sebenarnya sederhana. Bayangkan sebuah antrian di bank yang hanya melayani maksimal 10 orang per jam per loket. Jika ada orang yang mencoba mengantri lebih dari 10 kali dalam satu jam, sistem akan menolaknya dan meminta dia kembali nanti. Hal yang sama berlaku untuk API. Kita membatasi berapa kali sebuah IP address atau user bisa mengakses endpoint tertentu dalam periode waktu tertentu.
Di Laravel, implementasi rate limiting dilakukan menggunakan RateLimiter facade. Tempat terbaik untuk mendefinisikan rate limiter adalah di method boot() pada file app/Providers/AppServiceProvider.php. Mari kita mulai dengan contoh sederhana.
<?php
namespace App\\Providers;
use Illuminate\\Cache\\RateLimiting\\Limit;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\RateLimiter;
use Illuminate\\Support\\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
// Rate limiter untuk API umum
// Maksimal 60 request per menit
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)
->by($request->user()?->id ?: $request->ip());
});
}
}
Kode di atas mendefinisikan rate limiter dengan nama api yang membatasi 60 request per menit. Method by() menentukan identifier untuk tracking. Jika user sudah login, kita gunakan user ID. Jika belum, kita gunakan IP address. Ini penting karena jika hanya menggunakan IP, semua user di belakang satu NAT atau VPN akan berbagi limit yang sama.
Sekarang mari kita buat rate limiter yang lebih sophisticated untuk berbagai kebutuhan.
<?php
namespace App\\Providers;
use Illuminate\\Cache\\RateLimiting\\Limit;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\RateLimiter;
use Illuminate\\Support\\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Rate limiter untuk API umum
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)
->by($request->user()?->id ?: $request->ip());
});
// Rate limiter ketat untuk login
// Mencegah brute force attack
RateLimiter::for('login', function (Request $request) {
return Limit::perMinute(5)
->by($request->input('email') . '|' . $request->ip());
});
// Rate limiter untuk OTP/verification
// Sangat ketat karena sensitive
RateLimiter::for('otp', function (Request $request) {
return Limit::perHour(5)
->by($request->user()?->id ?: $request->ip());
});
// Rate limiter untuk export/download
// Resource intensive, perlu dibatasi
RateLimiter::for('export', function (Request $request) {
return Limit::perHour(10)
->by($request->user()?->id ?: $request->ip());
});
// Rate limiter untuk forgot password
// Mencegah email bombing
RateLimiter::for('forgot-password', function (Request $request) {
return Limit::perHour(3)
->by($request->input('email') . '|' . $request->ip());
});
// Rate limiter untuk upload
// Mencegah storage abuse
RateLimiter::for('upload', function (Request $request) {
return Limit::perDay(100)
->by($request->user()?->id ?: $request->ip());
});
}
}
Setiap rate limiter punya karakteristik berbeda sesuai dengan nature endpoint yang dilindungi. Untuk login, saya menggunakan kombinasi email dan IP sebagai identifier. Ini artinya seseorang hanya bisa mencoba login ke email tertentu sebanyak 5 kali per menit dari IP yang sama. Jika dia ganti IP, counter-nya reset, tapi setidaknya ini memperlambat brute force attack secara signifikan.
Untuk OTP dan forgot password, limitnya jauh lebih ketat karena ini adalah fitur sensitif yang sering menjadi target abuse. Lima request per jam untuk OTP sudah lebih dari cukup untuk legitimate use case. Jika ada yang mencoba lebih dari itu, kemungkinan besar itu adalah attempt untuk bypass verification atau melakukan social engineering.
Sekarang mari kita lihat bagaimana membuat rate limiter yang berbeda berdasarkan tipe user. Ini adalah pattern yang sangat umum di aplikasi SaaS di mana user premium mendapat limit lebih tinggi.
<?php
RateLimiter::for('api', function (Request $request) {
$user = $request->user();
// Guest user - limit paling ketat
if (!$user) {
return Limit::perMinute(20)->by($request->ip());
}
// Premium user - limit sangat longgar
if ($user->isPremium()) {
return Limit::perMinute(1000)->by($user->id);
}
// Pro user - limit menengah
if ($user->isPro()) {
return Limit::perMinute(300)->by($user->id);
}
// Regular authenticated user
return Limit::perMinute(60)->by($user->id);
});
Dengan setup seperti ini, user premium mendapat limit 1000 request per menit sementara guest hanya 20. Ini memberikan insentif untuk upgrade sekaligus melindungi sistem dari abuse oleh anonymous users.
Laravel juga mendukung rate limiting tanpa batas untuk user tertentu menggunakan Limit::none(). Ini berguna untuk admin atau service account internal.
<?php
RateLimiter::for('api', function (Request $request) {
$user = $request->user();
// Admin tidak dibatasi
if ($user?->isAdmin()) {
return Limit::none();
}
// Internal service account tidak dibatasi
if ($user?->isServiceAccount()) {
return Limit::none();
}
return Limit::perMinute(60)->by($user?->id ?: $request->ip());
});
Setelah mendefinisikan rate limiter, kita perlu menerapkannya ke routes. Cara paling mudah adalah menggunakan middleware throttle dengan nama limiter.
<?php
use Illuminate\\Support\\Facades\\Route;
// routes/api.php
// Semua routes di bawah ini menggunakan rate limiter 'api'
Route::middleware(['throttle:api'])->group(function () {
Route::get('/products', [ProductController::class, 'index']);
Route::get('/products/{id}', [ProductController::class, 'show']);
Route::post('/products', [ProductController::class, 'store']);
});
// Route dengan rate limiter khusus untuk login
Route::post('/login', [AuthController::class, 'login'])
->middleware('throttle:login');
// Route dengan rate limiter untuk forgot password
Route::post('/forgot-password', [AuthController::class, 'forgotPassword'])
->middleware('throttle:forgot-password');
// Route dengan rate limiter untuk OTP
Route::post('/verify-otp', [AuthController::class, 'verifyOtp'])
->middleware('throttle:otp');
// Route dengan rate limiter untuk export
Route::get('/reports/export', [ReportController::class, 'export'])
->middleware(['auth:sanctum', 'throttle:export']);
Ketika user melampaui rate limit, Laravel secara default akan mengembalikan response 429 Too Many Requests. Tapi response default ini kurang informatif. Mari kita buat custom response yang lebih helpful.
<?php
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)
->by($request->user()?->id ?: $request->ip())
->response(function (Request $request, array $headers) {
return response()->json([
'success' => false,
'message' => 'Terlalu banyak request. Silakan coba lagi nanti.',
'error_code' => 'RATE_LIMIT_EXCEEDED',
'retry_after_seconds' => $headers['Retry-After'],
'retry_after_human' => now()->addSeconds($headers['Retry-After'])->diffForHumans(),
], 429, $headers);
});
});
Response di atas jauh lebih informatif. User tahu bahwa mereka kena rate limit, berapa detik lagi mereka bisa mencoba, dan bahkan versi human-readable dari waktu tunggu. Header Retry-After juga tetap dikirim sehingga client yang well-behaved bisa melakukan automatic retry setelah waktu yang ditentukan.
Untuk rate limiter login, kita bisa membuat response yang lebih spesifik.
<?php
RateLimiter::for('login', function (Request $request) {
return Limit::perMinute(5)
->by($request->input('email') . '|' . $request->ip())
->response(function (Request $request, array $headers) {
$retryAfter = $headers['Retry-After'];
$minutes = ceil($retryAfter / 60);
return response()->json([
'success' => false,
'message' => "Terlalu banyak percobaan login. Silakan coba lagi dalam {$minutes} menit.",
'error_code' => 'LOGIN_RATE_LIMITED',
'retry_after_seconds' => $retryAfter,
], 429, $headers);
});
});
Ada kalanya kita perlu mengecek rate limit secara manual di dalam controller, bukan hanya sebagai middleware. Laravel menyediakan method untuk ini.
<?php
namespace App\\Http\\Controllers;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\RateLimiter;
class ApiController extends Controller
{
public function sensitiveAction(Request $request)
{
$key = 'sensitive-action:' . $request->user()->id;
// Cek apakah masih dalam limit
if (RateLimiter::tooManyAttempts($key, 5)) {
$seconds = RateLimiter::availableIn($key);
return response()->json([
'success' => false,
'message' => 'Aksi ini dibatasi. Coba lagi dalam ' . $seconds . ' detik.',
], 429);
}
// Increment counter
RateLimiter::hit($key, 60); // decay dalam 60 detik
// Lakukan aksi sensitif
// ...
return response()->json([
'success' => true,
'message' => 'Aksi berhasil dilakukan.',
'remaining_attempts' => RateLimiter::remaining($key, 5),
]);
}
}
Method tooManyAttempts() mengecek apakah key sudah melampaui batas. Method hit() menambah counter dengan parameter kedua adalah decay time dalam detik. Method availableIn() mengembalikan berapa detik lagi sampai limit reset. Method remaining() mengembalikan sisa attempt yang tersedia.
Untuk keamanan maksimal, ada pattern yang menggabungkan IP-based dan user-based limiting. Ini memberikan dua layer protection.
<?php
RateLimiter::for('double-protection', function (Request $request) {
$user = $request->user();
return [
// Layer 1: IP-based limit
// Melindungi dari single IP yang abuse
Limit::perMinute(100)->by($request->ip()),
// Layer 2: User-based limit (jika authenticated)
// Melindungi dari single user yang abuse dari multiple IP
$user
? Limit::perMinute(60)->by($user->id)
: Limit::perMinute(30)->by($request->ip()),
];
});
Ketika returning array of Limits, Laravel akan check semua limits dan request akan ditolak jika salah satu limit tercapai. Ini sangat powerful untuk skenario di mana attacker menggunakan botnet dengan banyak IP tapi satu account, atau sebaliknya banyak account tapi dari IP yang sama.
Terakhir, ada kalanya kita perlu mereset rate limit secara manual. Misalnya setelah user berhasil login, kita ingin clear failed attempt counter.
<?php
namespace App\\Http\\Controllers;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\RateLimiter;
class AuthController extends Controller
{
public function login(Request $request)
{
$credentials = $request->validate([
'email' => 'required|email',
'password' => 'required',
]);
$throttleKey = $request->input('email') . '|' . $request->ip();
if (!auth()->attempt($credentials)) {
// Login gagal, hit rate limiter
RateLimiter::hit($throttleKey, 60);
return response()->json([
'success' => false,
'message' => 'Email atau password salah.',
'remaining_attempts' => RateLimiter::remaining($throttleKey, 5),
], 401);
}
// Login berhasil, clear rate limiter
RateLimiter::clear($throttleKey);
$token = $request->user()->createToken('auth-token')->plainTextToken;
return response()->json([
'success' => true,
'message' => 'Login berhasil.',
'token' => $token,
]);
}
}
Method clear() menghapus semua counter untuk key tertentu. Ini memastikan user yang legitimate tidak terhambat oleh failed attempts sebelumnya setelah berhasil login.
Rate limiting mungkin terlihat seperti fitur kecil, tapi dampaknya sangat besar untuk security dan stability aplikasi. Developer yang memahami dan mengimplementasikan rate limiting dengan proper menunjukkan bahwa mereka berpikir beyond just making features work. Mereka memikirkan bagaimana melindungi sistem dari abuse, bagaimana memberikan experience yang fair untuk semua user, dan bagaimana menjaga infrastruktur tetap stable di bawah load yang tinggi.
Di bagian selanjutnya, kita akan membahas Role-Based Access Control yang merupakan fondasi untuk membangun sistem permission yang fleksibel dan scalable.
Bagian 3: Role-Based Access Control (RBAC) - Sistem Permission yang Fleksibel
Role-Based Access Control adalah sistem untuk mengatur siapa boleh melakukan apa di dalam aplikasi berdasarkan role dan permission yang dimiliki. Skill ini krusial untuk aplikasi enterprise di mana ada berbagai tipe user dengan akses berbeda seperti Super Admin, Admin, Manager, Staff, dan Customer. Developer yang menguasai RBAC bisa merancang sistem authorization yang fleksibel tanpa kode penuh if-else yang susah di-maintain.
Hampir setiap project serius yang saya tangani membutuhkan sistem permission yang lebih kompleks dari sekadar "admin atau bukan admin". Bayangkan aplikasi HR di mana Manager hanya bisa melihat data karyawan di departemennya, Finance hanya bisa akses data payroll, dan HR Admin bisa manage semua karyawan tapi tidak bisa lihat data keuangan. Tanpa RBAC yang proper, kode akan berantakan dan rawan security hole.
Mari kita bangun sistem RBAC dari nol menggunakan fitur native Laravel tanpa package eksternal. Pertama, kita buat migration untuk tabel-tabel yang dibutuhkan.
php artisan make:migration create_roles_table
php artisan make:migration create_permissions_table
php artisan make:migration create_role_user_table
php artisan make:migration create_permission_role_table
<?php
// database/migrations/xxxx_create_roles_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('roles', function (Blueprint $table) {
$table->id();
$table->string('name')->unique(); // admin, manager, staff
$table->string('display_name');
$table->text('description')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('roles');
}
};
<?php
// database/migrations/xxxx_create_permissions_table.php
return new class extends Migration
{
public function up(): void
{
Schema::create('permissions', function (Blueprint $table) {
$table->id();
$table->string('name')->unique(); // users.create, users.edit
$table->string('display_name');
$table->string('module'); // users, products, reports
$table->text('description')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('permissions');
}
};
<?php
// database/migrations/xxxx_create_role_user_table.php
return new class extends Migration
{
public function up(): void
{
Schema::create('role_user', function (Blueprint $table) {
$table->id();
$table->foreignId('role_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->timestamps();
$table->unique(['role_id', 'user_id']);
});
}
public function down(): void
{
Schema::dropIfExists('role_user');
}
};
<?php
// database/migrations/xxxx_create_permission_role_table.php
return new class extends Migration
{
public function up(): void
{
Schema::create('permission_role', function (Blueprint $table) {
$table->id();
$table->foreignId('permission_id')->constrained()->cascadeOnDelete();
$table->foreignId('role_id')->constrained()->cascadeOnDelete();
$table->timestamps();
$table->unique(['permission_id', 'role_id']);
});
}
public function down(): void
{
Schema::dropIfExists('permission_role');
}
};
Sekarang buat model Role dan Permission dengan relationship yang tepat.
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany;
class Role extends Model
{
protected $fillable = ['name', 'display_name', 'description'];
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class);
}
public function permissions(): BelongsToMany
{
return $this->belongsToMany(Permission::class);
}
}
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany;
class Permission extends Model
{
protected $fillable = ['name', 'display_name', 'module', 'description'];
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class);
}
}
Tambahkan trait HasRolesAndPermissions di model User untuk meng-handle semua logic authorization.
<?php
namespace App\\Traits;
use App\\Models\\Role;
use App\\Models\\Permission;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany;
trait HasRolesAndPermissions
{
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class);
}
public function hasRole(string|array $roles): bool
{
if (is_string($roles)) {
return $this->roles->contains('name', $roles);
}
return $this->roles->whereIn('name', $roles)->isNotEmpty();
}
public function hasPermission(string $permission): bool
{
// Cek permission dari semua role yang dimiliki user
foreach ($this->roles as $role) {
if ($role->permissions->contains('name', $permission)) {
return true;
}
}
return false;
}
public function getAllPermissions(): array
{
return $this->roles
->flatMap(fn ($role) => $role->permissions)
->pluck('name')
->unique()
->values()
->toArray();
}
public function assignRole(string $roleName): void
{
$role = Role::where('name', $roleName)->firstOrFail();
$this->roles()->syncWithoutDetaching($role);
}
public function removeRole(string $roleName): void
{
$role = Role::where('name', $roleName)->first();
if ($role) {
$this->roles()->detach($role);
}
}
}
<?php
namespace App\\Models;
use App\\Traits\\HasRolesAndPermissions;
use Illuminate\\Foundation\\Auth\\User as Authenticatable;
class User extends Authenticatable
{
use HasRolesAndPermissions;
// ... kode lainnya
}
Sekarang daftarkan Gate di AuthServiceProvider untuk mengintegrasikan dengan sistem authorization Laravel.
<?php
namespace App\\Providers;
use Illuminate\\Foundation\\Support\\Providers\\AuthServiceProvider as ServiceProvider;
use Illuminate\\Support\\Facades\\Gate;
class AuthServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Super admin bypass semua permission check
Gate::before(function ($user, $ability) {
if ($user->hasRole('super-admin')) {
return true;
}
});
// Daftarkan semua permissions sebagai Gate
Gate::define('users.view', fn ($user) => $user->hasPermission('users.view'));
Gate::define('users.create', fn ($user) => $user->hasPermission('users.create'));
Gate::define('users.edit', fn ($user) => $user->hasPermission('users.edit'));
Gate::define('users.delete', fn ($user) => $user->hasPermission('users.delete'));
Gate::define('products.view', fn ($user) => $user->hasPermission('products.view'));
Gate::define('products.create', fn ($user) => $user->hasPermission('products.create'));
Gate::define('products.edit', fn ($user) => $user->hasPermission('products.edit'));
Gate::define('products.delete', fn ($user) => $user->hasPermission('products.delete'));
Gate::define('reports.view', fn ($user) => $user->hasPermission('reports.view'));
Gate::define('reports.export', fn ($user) => $user->hasPermission('reports.export'));
}
}
Untuk aplikasi dengan banyak permission, lebih baik generate Gate secara dinamis dari database.
<?php
public function boot(): void
{
Gate::before(function ($user, $ability) {
if ($user->hasRole('super-admin')) {
return true;
}
});
// Dynamic gate dari database
// Cache permissions untuk performa
$permissions = cache()->remember('permissions', 3600, function () {
return \\App\\Models\\Permission::pluck('name')->toArray();
});
foreach ($permissions as $permission) {
Gate::define($permission, fn ($user) => $user->hasPermission($permission));
}
}
Buat middleware untuk role dan permission checking yang bisa digunakan di routes.
<?php
namespace App\\Http\\Middleware;
use Closure;
use Illuminate\\Http\\Request;
use Symfony\\Component\\HttpFoundation\\Response;
class CheckRole
{
public function handle(Request $request, Closure $next, string ...$roles): Response
{
if (!$request->user() || !$request->user()->hasRole($roles)) {
if ($request->expectsJson()) {
return response()->json([
'success' => false,
'message' => 'Anda tidak memiliki akses untuk halaman ini.',
'error_code' => 'FORBIDDEN',
], 403);
}
abort(403, 'Unauthorized action.');
}
return $next($request);
}
}
<?php
namespace App\\Http\\Middleware;
use Closure;
use Illuminate\\Http\\Request;
use Symfony\\Component\\HttpFoundation\\Response;
class CheckPermission
{
public function handle(Request $request, Closure $next, string $permission): Response
{
if (!$request->user() || !$request->user()->hasPermission($permission)) {
if ($request->expectsJson()) {
return response()->json([
'success' => false,
'message' => 'Anda tidak memiliki permission untuk aksi ini.',
'error_code' => 'FORBIDDEN',
], 403);
}
abort(403, 'Unauthorized action.');
}
return $next($request);
}
}
Daftarkan middleware di bootstrap/app.php (Laravel 11+).
<?php
use Illuminate\\Foundation\\Application;
use Illuminate\\Foundation\\Configuration\\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'role' => \\App\\Http\\Middleware\\CheckRole::class,
'permission' => \\App\\Http\\Middleware\\CheckPermission::class,
]);
})
->create();
Sekarang gunakan di routes.
<?php
use Illuminate\\Support\\Facades\\Route;
// Hanya admin dan super-admin yang bisa akses
Route::middleware(['auth', 'role:admin,super-admin'])->group(function () {
Route::get('/admin/dashboard', [AdminController::class, 'dashboard']);
});
// Berdasarkan permission spesifik
Route::middleware(['auth', 'permission:users.view'])->group(function () {
Route::get('/users', [UserController::class, 'index']);
});
Route::middleware(['auth', 'permission:users.create'])->group(function () {
Route::post('/users', [UserController::class, 'store']);
});
Route::middleware(['auth', 'permission:reports.export'])->group(function () {
Route::get('/reports/export', [ReportController::class, 'export']);
});
Di controller, gunakan Gate atau method authorize untuk checking.
<?php
namespace App\\Http\\Controllers;
use App\\Models\\User;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\Gate;
class UserController extends Controller
{
public function index()
{
// Cara 1: Menggunakan authorize
$this->authorize('users.view');
$users = User::with('roles')->paginate(20);
return response()->json([
'success' => true,
'data' => $users,
]);
}
public function destroy(User $user)
{
// Cara 2: Menggunakan Gate
if (Gate::denies('users.delete')) {
return response()->json([
'success' => false,
'message' => 'Anda tidak memiliki permission untuk menghapus user.',
], 403);
}
// Prevent self-deletion
if ($user->id === auth()->id()) {
return response()->json([
'success' => false,
'message' => 'Anda tidak bisa menghapus akun sendiri.',
], 400);
}
$user->delete();
return response()->json([
'success' => true,
'message' => 'User berhasil dihapus.',
]);
}
}
Di Blade template, gunakan directive @can untuk show/hide UI elements.
{{-- resources/views/users/index.blade.php --}}
<div class="container">
<h1>Daftar User</h1>
@can('users.create')
<a href="{{ route('users.create') }}" class="btn btn-primary">
Tambah User Baru
</a>
@endcan
<table class="table">
<thead>
<tr>
<th>Nama</th>
<th>Email</th>
<th>Role</th>
@canany(['users.edit', 'users.delete'])
<th>Aksi</th>
@endcanany
</tr>
</thead>
<tbody>
@foreach($users as $user)
<tr>
<td>{{ $user->name }}</td>
<td>{{ $user->email }}</td>
<td>{{ $user->roles->pluck('display_name')->join(', ') }}</td>
@canany(['users.edit', 'users.delete'])
<td>
@can('users.edit')
<a href="{{ route('users.edit', $user) }}" class="btn btn-sm btn-warning">
Edit
</a>
@endcan
@can('users.delete')
<form action="{{ route('users.destroy', $user) }}" method="POST" style="display:inline">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-sm btn-danger">
Hapus
</button>
</form>
@endcan
</td>
@endcanany
</tr>
@endforeach
</tbody>
</table>
</div>
Terakhir, buat seeder untuk data awal roles dan permissions.
<?php
namespace Database\\Seeders;
use App\\Models\\Permission;
use App\\Models\\Role;
use App\\Models\\User;
use Illuminate\\Database\\Seeder;
class RolesAndPermissionsSeeder extends Seeder
{
public function run(): void
{
// Buat permissions
$permissions = [
['name' => 'users.view', 'display_name' => 'Lihat User', 'module' => 'users'],
['name' => 'users.create', 'display_name' => 'Tambah User', 'module' => 'users'],
['name' => 'users.edit', 'display_name' => 'Edit User', 'module' => 'users'],
['name' => 'users.delete', 'display_name' => 'Hapus User', 'module' => 'users'],
['name' => 'products.view', 'display_name' => 'Lihat Produk', 'module' => 'products'],
['name' => 'products.create', 'display_name' => 'Tambah Produk', 'module' => 'products'],
['name' => 'products.edit', 'display_name' => 'Edit Produk', 'module' => 'products'],
['name' => 'products.delete', 'display_name' => 'Hapus Produk', 'module' => 'products'],
['name' => 'reports.view', 'display_name' => 'Lihat Laporan', 'module' => 'reports'],
['name' => 'reports.export', 'display_name' => 'Export Laporan', 'module' => 'reports'],
];
foreach ($permissions as $permission) {
Permission::create($permission);
}
// Buat roles
$superAdmin = Role::create([
'name' => 'super-admin',
'display_name' => 'Super Administrator',
'description' => 'Full access to all features',
]);
$admin = Role::create([
'name' => 'admin',
'display_name' => 'Administrator',
'description' => 'Administrative access',
]);
$manager = Role::create([
'name' => 'manager',
'display_name' => 'Manager',
'description' => 'Manager access with reports',
]);
$staff = Role::create([
'name' => 'staff',
'display_name' => 'Staff',
'description' => 'Basic staff access',
]);
// Assign permissions ke roles
$admin->permissions()->attach(Permission::all());
$manager->permissions()->attach(
Permission::whereIn('name', [
'users.view',
'products.view', 'products.create', 'products.edit',
'reports.view', 'reports.export',
])->get()
);
$staff->permissions()->attach(
Permission::whereIn('name', [
'products.view', 'products.create', 'products.edit',
])->get()
);
// Buat super admin user
$user = User::factory()->create([
'name' => 'Super Admin',
'email' => '[email protected]',
]);
$user->assignRole('super-admin');
}
}
Dengan sistem RBAC yang sudah dibangun, kalian bisa dengan mudah menambah role atau permission baru tanpa mengubah kode. Cukup tambahkan data di database dan sistem akan otomatis meng-handle authorization-nya. Ini adalah tanda aplikasi yang well-architected dan mudah di-maintain.
Di bagian selanjutnya, kita akan membahas Raw SQL Query untuk situasi di mana Eloquent tidak cukup performant.
Bagian 4: Raw SQL Query - Optimasi Performa untuk Query Kompleks
Raw SQL Query adalah kemampuan menulis query SQL langsung di Laravel untuk situasi di mana Eloquent tidak cukup performant atau tidak bisa menghasilkan query yang diinginkan. Skill ini membedakan developer senior dengan junior karena menunjukkan pemahaman mendalam tentang database, bukan hanya bergantung pada abstraksi ORM. Perusahaan sangat menghargai developer yang bisa turun ke level SQL ketika dibutuhkan untuk optimasi performa.
Jangan salah paham, Eloquent adalah tools yang luar biasa dan saya menggunakannya untuk 90% kebutuhan query. Tapi ada 10% situasi di mana Raw SQL adalah pilihan yang jauh lebih baik. Biasanya ini terjadi saat membuat reporting dengan agregasi kompleks, query dengan subquery bertingkat, bulk operations pada ratusan ribu data, atau memanfaatkan fitur database-specific seperti window functions.
Laravel menyediakan beberapa cara untuk menjalankan Raw SQL. Yang paling straightforward adalah menggunakan DB facade.
<?php
use Illuminate\\Support\\Facades\\DB;
// SELECT query - mengembalikan array of stdClass objects
$users = DB::select('SELECT * FROM users WHERE status = ?', ['active']);
// INSERT query - mengembalikan boolean
DB::insert('INSERT INTO logs (action, user_id, created_at) VALUES (?, ?, ?)', [
'login',
$userId,
now(),
]);
// UPDATE query - mengembalikan jumlah row yang affected
$affected = DB::update('UPDATE users SET last_login = ? WHERE id = ?', [
now(),
$userId,
]);
// DELETE query - mengembalikan jumlah row yang affected
$deleted = DB::delete('DELETE FROM sessions WHERE expired_at < ?', [now()]);
// Statement untuk DDL atau query tanpa return value
DB::statement('ALTER TABLE users ADD COLUMN phone VARCHAR(20) NULL');
Perhatikan penggunaan placeholder ? untuk parameter binding. Ini sangat penting untuk mencegah SQL Injection. Jangan pernah concatenate variable langsung ke string SQL.
<?php
// JANGAN LAKUKAN INI - vulnerable SQL Injection!
$email = $request->input('email');
$users = DB::select("SELECT * FROM users WHERE email = '$email'"); // BAHAYA!
// LAKUKAN INI - aman dengan parameter binding
$users = DB::select('SELECT * FROM users WHERE email = ?', [$email]);
// Atau dengan named binding
$users = DB::select('SELECT * FROM users WHERE email = :email', ['email' => $email]);
Sekarang mari kita lihat contoh nyata di mana Raw SQL jauh lebih baik dari Eloquent. Bayangkan kita perlu membuat laporan penjualan bulanan dengan growth percentage dibanding bulan sebelumnya.
<?php
namespace App\\Services;
use Illuminate\\Support\\Facades\\DB;
class ReportService
{
public function getMonthlySalesReport(int $year): array
{
$report = DB::select("
WITH monthly_data AS (
SELECT
DATE_FORMAT(created_at, '%Y-%m') as month,
DATE_FORMAT(created_at, '%M %Y') as month_name,
COUNT(*) as total_orders,
SUM(total_amount) as revenue,
COUNT(DISTINCT customer_id) as unique_customers
FROM orders
WHERE YEAR(created_at) = ?
AND status = 'completed'
GROUP BY DATE_FORMAT(created_at, '%Y-%m'), DATE_FORMAT(created_at, '%M %Y')
)
SELECT
month,
month_name,
total_orders,
revenue,
unique_customers,
ROUND(revenue / total_orders, 2) as avg_order_value,
LAG(revenue) OVER (ORDER BY month) as prev_revenue,
ROUND(
((revenue - LAG(revenue) OVER (ORDER BY month)) /
LAG(revenue) OVER (ORDER BY month)) * 100,
2) as growth_percentage
FROM monthly_data
ORDER BY month
", [$year]);
return $report;
}
}
Query di atas menggunakan CTE (Common Table Expression) dengan WITH clause dan window function LAG() untuk mengambil nilai bulan sebelumnya. Mencoba menulis ini dengan Eloquent akan sangat kompleks dan kemungkinan besar hasilnya tidak seefisien Raw SQL.
Contoh lain adalah ranking products berdasarkan penjualan per kategori.
<?php
public function getTopProductsByCategory(int $limit = 5): array
{
return DB::select("
WITH ranked_products AS (
SELECT
p.id,
p.name,
p.category_id,
c.name as category_name,
SUM(oi.quantity) as total_sold,
SUM(oi.quantity * oi.price) as total_revenue,
ROW_NUMBER() OVER (
PARTITION BY p.category_id
ORDER BY SUM(oi.quantity) DESC
) as rank_in_category
FROM products p
JOIN categories c ON p.category_id = c.id
JOIN order_items oi ON p.id = oi.product_id
JOIN orders o ON oi.order_id = o.id
WHERE o.status = 'completed'
AND o.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY p.id, p.name, p.category_id, c.name
)
SELECT *
FROM ranked_products
WHERE rank_in_category <= ?
ORDER BY category_name, rank_in_category
", [$limit]);
}
Window function ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) memberikan ranking untuk setiap produk dalam kategorinya. Ini adalah fitur SQL yang sangat powerful tapi tidak bisa diakses langsung melalui Eloquent.
Selain full Raw SQL, Laravel juga menyediakan cara untuk menyisipkan raw expression dalam Query Builder. Ini berguna ketika sebagian besar query bisa pakai Eloquent tapi ada satu bagian yang perlu raw.
<?php
use Illuminate\\Support\\Facades\\DB;
use App\\Models\\Order;
// selectRaw untuk custom select
$stats = Order::query()
->selectRaw('COUNT(*) as total_orders')
->selectRaw('SUM(total_amount) as total_revenue')
->selectRaw('AVG(total_amount) as avg_order_value')
->selectRaw('DATE(created_at) as order_date')
->where('status', 'completed')
->groupByRaw('DATE(created_at)')
->orderByRaw('DATE(created_at) DESC')
->limit(30)
->get();
// whereRaw untuk kondisi kompleks
$users = User::query()
->whereRaw('TIMESTAMPDIFF(YEAR, birth_date, CURDATE()) >= ?', [18])
->whereRaw('TIMESTAMPDIFF(YEAR, birth_date, CURDATE()) <= ?', [30])
->get();
// havingRaw untuk filter hasil agregasi
$topCustomers = Order::query()
->select('customer_id')
->selectRaw('SUM(total_amount) as lifetime_value')
->groupBy('customer_id')
->havingRaw('SUM(total_amount) > ?', [10000000])
->orderByRaw('SUM(total_amount) DESC')
->limit(100)
->get();
Untuk bulk operations, Raw SQL memberikan performa yang jauh lebih baik. Bayangkan perlu update harga ribuan produk sekaligus dengan logic berbeda per kategori.
<?php
public function bulkUpdatePrices(array $priceAdjustments): int
{
// $priceAdjustments = [
// ['category_id' => 1, 'percentage' => 10],
// ['category_id' => 2, 'percentage' => -5],
// ['category_id' => 3, 'percentage' => 15],
// ]
$cases = [];
$categoryIds = [];
foreach ($priceAdjustments as $adjustment) {
$cases[] = "WHEN category_id = {$adjustment['category_id']} THEN price * " . (1 + $adjustment['percentage'] / 100);
$categoryIds[] = $adjustment['category_id'];
}
$caseSql = implode(' ', $cases);
$categoryIdsSql = implode(',', $categoryIds);
return DB::update("
UPDATE products
SET price = CASE {$caseSql} ELSE price END,
updated_at = NOW()
WHERE category_id IN ({$categoryIdsSql})
");
}
Dengan satu query, ribuan produk bisa di-update sekaligus. Bandingkan dengan Eloquent yang harus loop dan update satu per satu, perbedaan performanya bisa 100x lipat.
Contoh lain bulk insert dengan INSERT ... ON DUPLICATE KEY UPDATE untuk upsert operation.
<?php
public function upsertProductStock(array $stockData): void
{
// $stockData = [
// ['product_id' => 1, 'warehouse_id' => 1, 'quantity' => 100],
// ['product_id' => 2, 'warehouse_id' => 1, 'quantity' => 50],
// ]
if (empty($stockData)) {
return;
}
$values = [];
$bindings = [];
foreach ($stockData as $stock) {
$values[] = '(?, ?, ?, NOW(), NOW())';
$bindings[] = $stock['product_id'];
$bindings[] = $stock['warehouse_id'];
$bindings[] = $stock['quantity'];
}
$valuesSql = implode(', ', $values);
DB::statement("
INSERT INTO product_stocks (product_id, warehouse_id, quantity, created_at, updated_at)
VALUES {$valuesSql}
ON DUPLICATE KEY UPDATE
quantity = VALUES(quantity),
updated_at = NOW()
", $bindings);
}
Untuk debugging, selalu verifikasi query yang dijalankan menggunakan query log atau method toSql().
<?php
use Illuminate\\Support\\Facades\\DB;
// Enable query log
DB::enableQueryLog();
// Jalankan query
$users = User::where('status', 'active')->get();
$orders = Order::with('items')->where('total', '>', 1000)->get();
// Lihat semua query yang dijalankan
$queries = DB::getQueryLog();
foreach ($queries as $query) {
dump([
'sql' => $query['query'],
'bindings' => $query['bindings'],
'time' => $query['time'] . 'ms',
]);
}
// Atau untuk satu query, lihat SQL tanpa execute
$sql = User::where('status', 'active')->toSql();
$bindings = User::where('status', 'active')->getBindings();
dump("SQL: {$sql}");
dump("Bindings: " . implode(', ', $bindings));
Kemampuan menulis Raw SQL yang efisien menunjukkan bahwa kalian memahami apa yang terjadi di balik abstraksi ORM. Ini adalah skill yang sangat dihargai terutama untuk posisi senior atau ketika bekerja dengan aplikasi yang handle data dalam jumlah besar.
Di bagian selanjutnya, kita akan membahas teknik Query Optimization yang lebih luas termasuk cara mengatasi N+1 problem dan teknik-teknik lain untuk membuat aplikasi tetap cepat meskipun data sudah jutaan baris.
Bagian 5: Query Optimization - Teknik Advanced untuk Database yang Efisien
Query Optimization adalah kemampuan mengidentifikasi dan memperbaiki query yang lambat agar aplikasi tetap responsif meskipun data sudah jutaan baris. Skill ini sangat dicari karena banyak aplikasi yang awalnya cepat menjadi sangat lambat setelah production beberapa bulan. Developer yang bisa diagnosa dan fix performance bottleneck adalah asset berharga yang perusahaan rela bayar mahal.
Masalah performa database biasanya tidak muncul saat development karena datanya masih sedikit. Baru terasa setelah production ketika user mulai komplain aplikasi lemot. Developer yang paham optimization bisa mencegah masalah ini dari awal atau memperbaikinya dengan cepat ketika terjadi.
Mari kita mulai dengan masalah paling umum di Laravel yaitu N+1 Query Problem. Ini terjadi ketika kita mengakses relasi di dalam loop tanpa eager loading.
<?php
// BURUK - N+1 Problem
// 1 query untuk ambil orders + N query untuk setiap customer
$orders = Order::all();
foreach ($orders as $order) {
echo $order->customer->name; // Query baru setiap iterasi!
}
// Jika ada 100 orders, total 101 queries dijalankan
// BAIK - Eager Loading dengan with()
// Hanya 2 queries: 1 untuk orders, 1 untuk semua customers
$orders = Order::with('customer')->get();
foreach ($orders as $order) {
echo $order->customer->name; // Tidak ada query tambahan
}
Untuk relasi bertingkat, gunakan dot notation.
<?php
// Eager load nested relations
$orders = Order::with([
'customer',
'items.product.category',
'shipping',
'payment',
])->get();
// Eager load dengan kondisi
$users = User::with(['posts' => function ($query) {
$query->where('status', 'published')
->orderBy('created_at', 'desc')
->limit(5);
}])->get();
// Eager load dengan select spesifik untuk hemat memory
$orders = Order::with(['customer:id,name,email', 'items:id,order_id,product_id,quantity,price'])
->get();
Untuk mencegah N+1 problem lolos ke production, aktifkan lazy loading prevention di development.
<?php
// app/Providers/AppServiceProvider.php
namespace App\\Providers;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Support\\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Throw exception jika ada lazy loading di non-production
Model::preventLazyLoading(!app()->isProduction());
// Atau log warning tanpa throw exception
Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
logger()->warning("Lazy loading detected: {$model}::{$relation}");
});
}
}
Ketika memproses data besar, jangan gunakan get() atau all() karena akan load semua data ke memory. Gunakan chunking atau cursor.
<?php
// BURUK - Load semua ke memory, bisa out of memory
$users = User::all();
foreach ($users as $user) {
$user->sendNewsletter();
}
// BAIK - Chunk: proses batch per batch
User::where('subscribed', true)
->chunk(1000, function ($users) {
foreach ($users as $user) {
$user->sendNewsletter();
}
});
// BAIK - ChunkById: lebih aman untuk data yang mungkin berubah
User::where('subscribed', true)
->chunkById(1000, function ($users) {
foreach ($users as $user) {
$user->sendNewsletter();
}
});
// BAIK - Lazy collection: memory efficient, satu record per waktu
User::where('subscribed', true)
->lazy()
->each(function ($user) {
$user->sendNewsletter();
});
// BAIK - Cursor: paling memory efficient tapi connection tetap open
User::where('subscribed', true)
->cursor()
->each(function ($user) {
$user->sendNewsletter();
});
Perbedaan utamanya adalah chunk() menjalankan multiple queries dengan LIMIT OFFSET, lazy() sama tapi return LazyCollection, sedangkan cursor() menggunakan PHP Generator dengan satu query yang di-stream. Gunakan chunk() untuk proses yang membutuhkan waktu lama per record, dan cursor() untuk proses cepat yang butuh memory minimal.
Hindari SELECT * dengan memilih kolom yang dibutuhkan saja.
<?php
// BURUK - Ambil semua kolom termasuk yang tidak perlu
$users = User::all();
// BAIK - Hanya ambil kolom yang dibutuhkan
$users = User::select(['id', 'name', 'email'])->get();
// Untuk API response, definisikan di Resource atau langsung
$users = User::select(['id', 'name', 'email', 'created_at'])
->where('status', 'active')
->get()
->map(fn ($user) => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'member_since' => $user->created_at->format('d M Y'),
]);
Gunakan subquery untuk menghindari multiple queries atau join yang kompleks.
<?php
use Illuminate\\Database\\Query\\Builder;
// Tambah kolom dari subquery
$users = User::query()
->select(['users.*'])
->addSelect([
'last_order_date' => Order::select('created_at')
->whereColumn('user_id', 'users.id')
->orderByDesc('created_at')
->limit(1),
'total_orders' => Order::selectRaw('COUNT(*)')
->whereColumn('user_id', 'users.id'),
'lifetime_value' => Order::selectRaw('COALESCE(SUM(total_amount), 0)')
->whereColumn('user_id', 'users.id')
->where('status', 'completed'),
])
->get();
// Filter berdasarkan subquery
$activeCustomers = User::query()
->whereIn('id', function (Builder $query) {
$query->select('user_id')
->from('orders')
->where('created_at', '>=', now()->subDays(30));
})
->get();
Untuk query yang hasilnya jarang berubah, gunakan caching.
<?php
// Cache hasil query
$categories = cache()->remember('product_categories', 3600, function () {
return Category::with('subcategories')
->where('is_active', true)
->orderBy('name')
->get();
});
// Cache dengan tags untuk invalidation lebih mudah
$topProducts = cache()->tags(['products', 'homepage'])->remember(
'top_products',
1800,
function () {
return Product::query()
->select(['id', 'name', 'price', 'image'])
->withCount('orders')
->orderByDesc('orders_count')
->limit(10)
->get();
}
);
// Invalidate cache saat data berubah
// Di Observer atau Event Listener
cache()->tags(['products'])->flush();
Gunakan index dengan tepat. Tambahkan index pada kolom yang sering digunakan di WHERE, JOIN, dan ORDER BY.
<?php
// Migration untuk menambahkan index
return new class extends Migration
{
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
// Single column index
$table->index('status');
$table->index('created_at');
// Composite index untuk query yang filter multiple columns
$table->index(['user_id', 'status']);
$table->index(['status', 'created_at']);
});
}
};
Verifikasi index digunakan dengan EXPLAIN.
<?php
// Cek apakah query menggunakan index
$explain = DB::select('EXPLAIN SELECT * FROM orders WHERE status = ? AND created_at > ?', [
'pending',
now()->subDays(7),
]);
dump($explain);
// Perhatikan kolom 'type' dan 'key'
// type = 'ref' atau 'range' = bagus, pakai index
// type = 'ALL' = full table scan, perlu optimasi
Untuk count yang tidak perlu akurat real-time, gunakan estimasi.
<?php
// LAMBAT untuk tabel besar
$totalUsers = User::count();
// CEPAT - gunakan estimasi dari MySQL
$estimate = DB::select("SHOW TABLE STATUS LIKE 'users'")[0]->Rows;
// Atau cache count dan update periodik
$totalUsers = cache()->remember('users_count', 300, function () {
return User::count();
});
Terakhir, selalu monitor query di production menggunakan tools seperti Laravel Telescope, Debugbar, atau langsung dari MySQL slow query log.
<?php
// Buat command untuk analisis slow queries
namespace App\\Console\\Commands;
use Illuminate\\Console\\Command;
use Illuminate\\Support\\Facades\\DB;
class AnalyzeSlowQueries extends Command
{
protected $signature = 'db:analyze-slow';
protected $description = 'Analyze slow queries from log';
public function handle(): void
{
DB::enableQueryLog();
// Simulasi atau jalankan beberapa endpoint
// ...
$queries = collect(DB::getQueryLog())
->sortByDesc('time')
->take(10);
$this->table(
['Time (ms)', 'Query'],
$queries->map(fn ($q) => [
round($q['time'], 2),
substr($q['query'], 0, 100) . '...',
])
);
}
}
Query optimization bukan one-time task tapi proses berkelanjutan. Seiring data bertambah, query yang tadinya cepat bisa jadi lambat. Developer yang memahami teknik-teknik ini bisa menjaga aplikasi tetap performant dalam jangka panjang.
Di bagian selanjutnya, kita akan membahas Job Queue untuk memproses task berat di background tanpa membuat user menunggu.
Bagian 6: Job Queue dan Background Processing - Menangani Task Berat dengan Elegan
Job Queue adalah sistem untuk menjalankan task yang membutuhkan waktu lama di background tanpa membuat user menunggu. Skill ini essential untuk aplikasi production karena banyak operasi seperti mengirim email, generate report, process upload, atau sync data ke third-party yang tidak bisa dijalankan synchronous. Developer yang menguasai queue bisa membangun aplikasi yang responsif dan scalable.
Bayangkan user melakukan order dan aplikasi harus mengirim email konfirmasi, update inventory, notify admin, dan sync ke sistem accounting. Jika semua ini dijalankan synchronous, user harus menunggu 10-30 detik. Dengan queue, response bisa kembali dalam hitungan milidetik sementara task berat diproses di background.
Pertama, konfigurasi queue driver di file .env. Untuk development bisa pakai database, untuk production sebaiknya redis.
QUEUE_CONNECTION=database
Jika pakai database driver, jalankan migration untuk membuat tabel jobs.
php artisan queue:table
php artisan migrate
Sekarang buat Job pertama untuk mengirim welcome email.
php artisan make:job SendWelcomeEmail
<?php
namespace App\\Jobs;
use App\\Mail\\WelcomeEmail;
use App\\Models\\User;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Foundation\\Bus\\Dispatchable;
use Illuminate\\Queue\\InteractsWithQueue;
use Illuminate\\Queue\\SerializesModels;
use Illuminate\\Support\\Facades\\Mail;
class SendWelcomeEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public User $user
) {}
public function handle(): void
{
Mail::to($this->user->email)->send(new WelcomeEmail($this->user));
}
}
Interface ShouldQueue menandakan job akan diproses di background. Trait SerializesModels memastikan Eloquent model di-serialize dengan benar. Dispatch job dari controller atau service.
<?php
namespace App\\Http\\Controllers;
use App\\Jobs\\SendWelcomeEmail;
use App\\Models\\User;
use Illuminate\\Http\\Request;
class AuthController extends Controller
{
public function register(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users',
'password' => 'required|min:8|confirmed',
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => bcrypt($validated['password']),
]);
// Dispatch job ke queue
SendWelcomeEmail::dispatch($user);
// Response langsung kembali tanpa menunggu email terkirim
return response()->json([
'success' => true,
'message' => 'Registrasi berhasil! Email konfirmasi akan segera dikirim.',
'data' => $user,
], 201);
}
}
Untuk menjalankan queue worker yang memproses jobs.
php artisan queue:work
Job bisa dikonfigurasi dengan delay, queue name, dan retry attempts.
<?php
// Delay job 5 menit
SendWelcomeEmail::dispatch($user)->delay(now()->addMinutes(5));
// Kirim ke queue tertentu
SendWelcomeEmail::dispatch($user)->onQueue('emails');
// Kombinasi
SendWelcomeEmail::dispatch($user)
->onQueue('emails')
->delay(now()->addMinutes(5));
Jalankan worker untuk queue spesifik dengan prioritas.
php artisan queue:work --queue=high,emails,default
Untuk task yang perlu retry jika gagal, konfigurasi di dalam job.
<?php
namespace App\\Jobs;
use Exception;
use App\\Models\\Order;
use App\\Services\\PaymentGateway;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Foundation\\Bus\\Dispatchable;
use Illuminate\\Queue\\InteractsWithQueue;
use Illuminate\\Queue\\SerializesModels;
class ProcessPayment implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3; // Maksimal 3 kali percobaan
public int $backoff = 60; // Tunggu 60 detik sebelum retry
public int $timeout = 120; // Timeout setelah 2 menit
public function __construct(
public Order $order
) {}
public function handle(PaymentGateway $gateway): void
{
$result = $gateway->charge($this->order);
if ($result->success) {
$this->order->update(['payment_status' => 'paid']);
} else {
throw new Exception('Payment failed: ' . $result->message);
}
}
// Dipanggil ketika semua retry gagal
public function failed(Exception $exception): void
{
$this->order->update(['payment_status' => 'failed']);
// Notify admin
logger()->error('Payment failed for order: ' . $this->order->id, [
'error' => $exception->getMessage(),
]);
}
}
Untuk menjalankan beberapa job secara berurutan, gunakan Job Chaining.
<?php
use App\\Jobs\\ProcessUpload;
use App\\Jobs\\GenerateThumbnail;
use App\\Jobs\\NotifyUser;
use Illuminate\\Support\\Facades\\Bus;
Bus::chain([
new ProcessUpload($file),
new GenerateThumbnail($file),
new NotifyUser($user, 'Upload selesai diproses'),
])->dispatch();
Job Batching untuk menjalankan banyak job dan melakukan action setelah semua selesai.
<?php
use App\\Jobs\\ProcessCsvRow;
use Illuminate\\Bus\\Batch;
use Illuminate\\Support\\Facades\\Bus;
// Buat migration untuk batch table
// php artisan queue:batches-table
// php artisan migrate
public function importCsv(Request $request)
{
$rows = $this->parseCsv($request->file('csv'));
$jobs = collect($rows)->map(fn ($row) => new ProcessCsvRow($row));
$batch = Bus::batch($jobs)
->then(function (Batch $batch) {
// Semua job sukses
logger()->info("Batch {$batch->id} completed successfully");
})
->catch(function (Batch $batch, \\Throwable $e) {
// Ada job yang gagal
logger()->error("Batch {$batch->id} failed: " . $e->getMessage());
})
->finally(function (Batch $batch) {
// Dipanggil setelah batch selesai (sukses atau gagal)
Notification::send($batch->createdBy, new ImportCompleted($batch));
})
->name('CSV Import')
->dispatch();
return response()->json([
'success' => true,
'message' => 'Import sedang diproses',
'batch_id' => $batch->id,
]);
}
// Endpoint untuk cek progress
public function checkBatchProgress(string $batchId)
{
$batch = Bus::findBatch($batchId);
return response()->json([
'total_jobs' => $batch->totalJobs,
'pending_jobs' => $batch->pendingJobs,
'failed_jobs' => $batch->failedJobs,
'progress' => $batch->progress(),
'finished' => $batch->finished(),
]);
}
Untuk production, gunakan Supervisor untuk menjaga queue worker tetap berjalan.
; /etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/www/html/storage/logs/worker.log
stopwaitsecs=3600
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start laravel-worker:*
Job queue adalah fondasi untuk membangun aplikasi yang bisa handle load tinggi tanpa membuat user menunggu. Dengan pattern seperti job chaining dan batching, kalian bisa membangun workflow kompleks yang reliable dan mudah di-monitor.
Di bagian selanjutnya, kita akan membahas Event dan Listener untuk membangun arsitektur yang loosely coupled dan mudah di-extend.
Bagian 7: Event dan Listener - Arsitektur yang Loosely Coupled
Event dan Listener adalah pattern untuk memisahkan aksi utama dari side effects yang mengikutinya, menciptakan arsitektur yang loosely coupled dan mudah di-extend. Skill ini menandakan developer yang mature karena menunjukkan pemahaman tentang separation of concerns. Dengan event-driven architecture, menambah fitur baru tidak perlu mengubah kode existing, cukup tambahkan listener baru.
Bayangkan ketika user register, aplikasi harus mengirim welcome email, create default settings, notify admin, track analytics, dan give welcome bonus. Tanpa events, semua logic ini akan menumpuk di satu controller yang gemuk dan susah di-test. Dengan events, setiap side effect punya listener sendiri yang bisa diubah atau dimatikan tanpa mempengaruhi yang lain.
Buat event dengan Artisan command.
php artisan make:event UserRegistered
<?php
namespace App\\Events;
use App\\Models\\User;
use Illuminate\\Broadcasting\\InteractsWithSockets;
use Illuminate\\Foundation\\Events\\Dispatchable;
use Illuminate\\Queue\\SerializesModels;
class UserRegistered
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public User $user
) {}
}
Sekarang buat beberapa listener untuk merespons event ini.
php artisan make:listener SendWelcomeEmail --event=UserRegistered
php artisan make:listener CreateDefaultSettings --event=UserRegistered
php artisan make:listener NotifyAdminNewUser --event=UserRegistered
php artisan make:listener TrackRegistration --event=UserRegistered
<?php
namespace App\\Listeners;
use App\\Events\\UserRegistered;
use App\\Mail\\WelcomeEmail;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Support\\Facades\\Mail;
class SendWelcomeEmail implements ShouldQueue
{
public function handle(UserRegistered $event): void
{
Mail::to($event->user->email)->send(new WelcomeEmail($event->user));
}
}
<?php
namespace App\\Listeners;
use App\\Events\\UserRegistered;
class CreateDefaultSettings
{
public function handle(UserRegistered $event): void
{
$event->user->settings()->create([
'notification_email' => true,
'notification_push' => true,
'theme' => 'light',
'language' => 'id',
]);
}
}
<?php
namespace App\\Listeners;
use App\\Events\\UserRegistered;
use App\\Notifications\\NewUserRegistered;
use App\\Models\\User;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
class NotifyAdminNewUser implements ShouldQueue
{
public string $queue = 'notifications';
public function handle(UserRegistered $event): void
{
$admins = User::role('admin')->get();
foreach ($admins as $admin) {
$admin->notify(new NewUserRegistered($event->user));
}
}
}
<?php
namespace App\\Listeners;
use App\\Events\\UserRegistered;
use App\\Services\\AnalyticsService;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
class TrackRegistration implements ShouldQueue
{
public function __construct(
private AnalyticsService $analytics
) {}
public function handle(UserRegistered $event): void
{
$this->analytics->track('user_registered', [
'user_id' => $event->user->id,
'source' => request()->header('referer'),
'device' => request()->header('user-agent'),
]);
}
}
Daftarkan event dan listeners di EventServiceProvider.
<?php
namespace App\\Providers;
use App\\Events\\UserRegistered;
use App\\Listeners\\CreateDefaultSettings;
use App\\Listeners\\NotifyAdminNewUser;
use App\\Listeners\\SendWelcomeEmail;
use App\\Listeners\\TrackRegistration;
use Illuminate\\Foundation\\Support\\Providers\\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
protected $listen = [
UserRegistered::class => [
SendWelcomeEmail::class,
CreateDefaultSettings::class,
NotifyAdminNewUser::class,
TrackRegistration::class,
],
];
}
Dispatch event dari controller.
<?php
namespace App\\Http\\Controllers;
use App\\Events\\UserRegistered;
use App\\Models\\User;
use Illuminate\\Http\\Request;
class AuthController extends Controller
{
public function register(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users',
'password' => 'required|min:8|confirmed',
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => bcrypt($validated['password']),
]);
// Dispatch event - semua listener akan dijalankan
UserRegistered::dispatch($user);
return response()->json([
'success' => true,
'message' => 'Registrasi berhasil!',
'data' => $user,
], 201);
}
}
Controller jadi sangat clean. Ketika ada requirement baru seperti "berikan welcome bonus 10rb untuk user baru", cukup buat listener baru tanpa mengubah controller.
<?php
namespace App\\Listeners;
use App\\Events\\UserRegistered;
class GiveWelcomeBonus
{
public function handle(UserRegistered $event): void
{
$event->user->wallet()->create([
'balance' => 10000,
'description' => 'Welcome bonus',
]);
}
}
Tambahkan ke EventServiceProvider dan selesai. Tidak ada kode existing yang diubah.
Untuk model events, Laravel menyediakan Observer pattern yang lebih clean.
php artisan make:observer OrderObserver --model=Order
<?php
namespace App\\Observers;
use App\\Models\\Order;
use App\\Services\\InventoryService;
use App\\Services\\InvoiceService;
class OrderObserver
{
public function __construct(
private InventoryService $inventory,
private InvoiceService $invoice
) {}
public function created(Order $order): void
{
// Generate invoice number
$order->update([
'invoice_number' => $this->invoice->generateNumber($order),
]);
}
public function updating(Order $order): void
{
// Log status change
if ($order->isDirty('status')) {
$order->statusLogs()->create([
'from_status' => $order->getOriginal('status'),
'to_status' => $order->status,
'changed_by' => auth()->id(),
]);
}
}
public function updated(Order $order): void
{
// Jika status berubah ke 'completed', update inventory
if ($order->wasChanged('status') && $order->status === 'completed') {
$this->inventory->decreaseStock($order->items);
}
}
public function deleted(Order $order): void
{
// Restore inventory jika order dihapus
if ($order->status !== 'completed') {
return;
}
$this->inventory->restoreStock($order->items);
}
}
Register observer di AppServiceProvider.
<?php
namespace App\\Providers;
use App\\Models\\Order;
use App\\Observers\\OrderObserver;
use Illuminate\\Support\\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Order::observe(OrderObserver::class);
}
}
Sekarang setiap operasi pada Order akan otomatis trigger logic di observer tanpa perlu panggil manual di setiap tempat.
Event-driven architecture membuat kode lebih modular, testable, dan mudah di-extend. Ketika ada bug di salah satu listener, listener lain tetap berjalan. Ketika ada fitur baru, cukup tambah listener baru tanpa risiko break fitur existing.
Di bagian selanjutnya, kita akan membahas Custom Artisan Command untuk automation dan task maintenance.
Bagian 8: Custom Artisan Command - Automation untuk Produktivitas
Custom Artisan Command adalah kemampuan membuat CLI tools untuk automation, maintenance, dan task-task yang perlu dijalankan dari terminal. Skill ini sangat valuable karena developer yang bisa membuat automation tools adalah asset besar untuk tim. Command bisa digunakan untuk data migration, cleanup, report generation, health check, dan banyak task repetitif lainnya yang menghemat waktu.
Setiap project pasti punya task yang harus dijalankan berkala seperti hapus data expired, generate laporan harian, sync data dari external API, atau kirim reminder ke user. Daripada melakukan manual atau bikin script terpisah, lebih baik buat sebagai Artisan command yang terintegrasi dengan aplikasi.
Buat command dengan Artisan.
php artisan make:command CleanupExpiredSessions
<?php
namespace App\\Console\\Commands;
use Illuminate\\Console\\Command;
use Illuminate\\Support\\Facades\\DB;
class CleanupExpiredSessions extends Command
{
protected $signature = 'cleanup:sessions {--days=30 : Days to keep sessions}';
protected $description = 'Delete expired sessions older than specified days';
public function handle(): int
{
$days = $this->option('days');
$cutoffDate = now()->subDays($days);
$this->info("Cleaning sessions older than {$days} days...");
$deleted = DB::table('sessions')
->where('last_activity', '<', $cutoffDate->timestamp)
->delete();
$this->info("✓ Deleted {$deleted} expired sessions.");
return Command::SUCCESS;
}
}
Jalankan dengan php artisan cleanup:sessions atau php artisan cleanup:sessions --days=7.
Buat command yang lebih kompleks dengan arguments, options, dan interactive prompts.
<?php
namespace App\\Console\\Commands;
use App\\Models\\Order;
use App\\Models\\User;
use Illuminate\\Console\\Command;
use Illuminate\\Support\\Facades\\Storage;
class GenerateReport extends Command
{
protected $signature = 'report:generate
{type : Report type (sales, users, products)}
{--from= : Start date (Y-m-d)}
{--to= : End date (Y-m-d)}
{--format=csv : Output format (csv, json, xlsx)}
{--email= : Send report to email}';
protected $description = 'Generate various reports';
public function handle(): int
{
$type = $this->argument('type');
$from = $this->option('from') ?? now()->startOfMonth()->format('Y-m-d');
$to = $this->option('to') ?? now()->format('Y-m-d');
$format = $this->option('format');
$this->info("Generating {$type} report from {$from} to {$to}...");
$data = match ($type) {
'sales' => $this->getSalesData($from, $to),
'users' => $this->getUsersData($from, $to),
'products' => $this->getProductsData($from, $to),
default => null,
};
if (!$data) {
$this->error("Invalid report type: {$type}");
return Command::FAILURE;
}
$filename = "{$type}_report_{$from}_to_{$to}.{$format}";
$this->exportReport($data, $filename, $format);
$this->info("✓ Report saved: storage/app/reports/{$filename}");
if ($email = $this->option('email')) {
$this->sendReportEmail($email, $filename);
$this->info("✓ Report sent to {$email}");
}
return Command::SUCCESS;
}
private function getSalesData(string $from, string $to): array
{
return Order::whereBetween('created_at', [$from, $to])
->where('status', 'completed')
->with('customer:id,name,email')
->get()
->map(fn ($order) => [
'order_id' => $order->id,
'customer' => $order->customer->name,
'total' => $order->total_amount,
'date' => $order->created_at->format('Y-m-d'),
])
->toArray();
}
private function getUsersData(string $from, string $to): array
{
return User::whereBetween('created_at', [$from, $to])
->get(['id', 'name', 'email', 'created_at'])
->toArray();
}
private function getProductsData(string $from, string $to): array
{
// Implementation
return [];
}
private function exportReport(array $data, string $filename, string $format): void
{
$content = match ($format) {
'json' => json_encode($data, JSON_PRETTY_PRINT),
'csv' => $this->arrayToCsv($data),
default => json_encode($data),
};
Storage::put("reports/{$filename}", $content);
}
private function arrayToCsv(array $data): string
{
if (empty($data)) return '';
$output = fopen('php://temp', 'r+');
fputcsv($output, array_keys($data[0]));
foreach ($data as $row) {
fputcsv($output, $row);
}
rewind($output);
$csv = stream_get_contents($output);
fclose($output);
return $csv;
}
private function sendReportEmail(string $email, string $filename): void
{
// Send email with attachment
}
}
Untuk task dengan banyak data, tampilkan progress bar.
<?php
namespace App\\Console\\Commands;
use App\\Models\\User;
use Illuminate\\Console\\Command;
class SendMonthlyNewsletter extends Command
{
protected $signature = 'newsletter:send {--test : Send to test email only}';
protected $description = 'Send monthly newsletter to all subscribed users';
public function handle(): int
{
if ($this->option('test')) {
$this->info('Sending test newsletter...');
// Send to test email
return Command::SUCCESS;
}
$users = User::where('newsletter_subscribed', true)->get();
$total = $users->count();
if ($total === 0) {
$this->warn('No subscribed users found.');
return Command::SUCCESS;
}
if (!$this->confirm("Send newsletter to {$total} users?")) {
$this->info('Cancelled.');
return Command::SUCCESS;
}
$bar = $this->output->createProgressBar($total);
$bar->start();
$sent = 0;
$failed = 0;
foreach ($users as $user) {
try {
// Send newsletter
$sent++;
} catch (\\Exception $e) {
$failed++;
logger()->error("Newsletter failed for {$user->email}: {$e->getMessage()}");
}
$bar->advance();
}
$bar->finish();
$this->newLine(2);
$this->info("✓ Sent: {$sent}");
if ($failed > 0) {
$this->error("✗ Failed: {$failed}");
}
return Command::SUCCESS;
}
}
Command bisa dijadwalkan di routes/console.php (Laravel 11+) atau app/Console/Kernel.php.
<?php
// routes/console.php
use Illuminate\\Support\\Facades\\Schedule;
// Setiap hari jam 1 malam
Schedule::command('cleanup:sessions --days=30')->dailyAt('01:00');
// Setiap Senin jam 8 pagi
Schedule::command('report:generate sales [email protected]')
->weeklyOn(1, '08:00')
->emailOutputOnFailure('[email protected]');
// Setiap awal bulan
Schedule::command('newsletter:send')
->monthlyOn(1, '09:00')
->withoutOverlapping();
// Setiap jam, skip jika masih running
Schedule::command('sync:external-data')
->hourly()
->withoutOverlapping()
->runInBackground();
Aktifkan scheduler dengan menambahkan cron entry di server.
* * * * * cd /path-to-project && php artisan schedule:run >> /dev/null 2>&1
Untuk output yang lebih informatif, gunakan table formatting.
<?php
namespace App\\Console\\Commands;
use App\\Models\\User;
use Illuminate\\Console\\Command;
use Illuminate\\Support\\Facades\\DB;
class SystemHealthCheck extends Command
{
protected $signature = 'system:health';
protected $description = 'Check system health status';
public function handle(): int
{
$this->info('Running system health check...');
$this->newLine();
$checks = [
['Check', 'Status', 'Details'],
['Database', $this->checkDatabase() ? '✓ OK' : '✗ FAIL', 'MySQL connection'],
['Cache', $this->checkCache() ? '✓ OK' : '✗ FAIL', 'Redis connection'],
['Storage', $this->checkStorage() ? '✓ OK' : '✗ FAIL', 'Writable directories'],
['Queue', $this->checkQueue() ? '✓ OK' : '✗ FAIL', 'Pending jobs: ' . $this->getPendingJobs()],
];
$this->table($checks[0], array_slice($checks, 1));
$this->newLine();
$this->info('Statistics:');
$this->line(" Total Users: " . User::count());
$this->line(" Active Sessions: " . DB::table('sessions')->count());
$this->line(" Disk Usage: " . $this->getDiskUsage());
return Command::SUCCESS;
}
private function checkDatabase(): bool
{
try {
DB::connection()->getPdo();
return true;
} catch (\\Exception $e) {
return false;
}
}
private function checkCache(): bool
{
try {
cache()->put('health_check', true, 1);
return cache()->get('health_check') === true;
} catch (\\Exception $e) {
return false;
}
}
private function checkStorage(): bool
{
return is_writable(storage_path('logs'));
}
private function checkQueue(): bool
{
return $this->getPendingJobs() < 1000;
}
private function getPendingJobs(): int
{
return DB::table('jobs')->count();
}
private function getDiskUsage(): string
{
$bytes = disk_free_space('/');
return round($bytes / 1073741824, 2) . ' GB free';
}
}
Custom Artisan Commands mengubah task manual yang membosankan menjadi otomatis dan terdokumentasi. Setiap command punya --help yang menjelaskan cara penggunaannya, bisa di-schedule, dan terintegrasi dengan ekosistem Laravel.
Di bagian selanjutnya, kita akan membahas API Versioning untuk maintain backward compatibility dengan multiple client versions.
Bagian 9: API Versioning - Backward Compatibility untuk API yang Mature
API Versioning adalah strategi untuk mengelola perubahan API tanpa breaking existing clients yang masih menggunakan versi lama. Skill ini menunjukkan maturity sebagai developer karena di dunia nyata, API digunakan oleh mobile app yang tidak bisa dipaksa update, third-party integrations, dan berbagai client dengan timeline update berbeda.
Developer yang paham versioning bisa evolve API dengan aman tanpa merusak ekosistem yang sudah berjalan.
Saya pernah melihat tim yang mengubah response structure API tanpa versioning. Akibatnya, semua mobile app yang sudah di-publish crash dan butuh waktu berminggu-minggu untuk recovery karena harus menunggu app store approval.
Dengan versioning yang proper, perubahan bisa dilakukan gradual sambil memberi waktu client untuk migrate.
Strategi paling umum dan straightforward adalah URI versioning dengan prefix /api/v1/, /api/v2/. Mari kita implementasikan.
Pertama, setup folder structure untuk versioned controllers.
app/Http/Controllers/Api/
├── V1/
│ ├── UserController.php
│ ├── ProductController.php
│ └── OrderController.php
├── V2/
│ ├── UserController.php
│ ├── ProductController.php
│ └── OrderController.php
Buat route files terpisah untuk setiap versi.
<?php
// routes/api_v1.php
use App\\Http\\Controllers\\Api\\V1\\UserController;
use App\\Http\\Controllers\\Api\\V1\\ProductController;
use App\\Http\\Controllers\\Api\\V1\\OrderController;
use Illuminate\\Support\\Facades\\Route;
Route::prefix('v1')->group(function () {
Route::apiResource('users', UserController::class);
Route::apiResource('products', ProductController::class);
Route::apiResource('orders', OrderController::class);
});
<?php
// routes/api_v2.php
use App\\Http\\Controllers\\Api\\V2\\UserController;
use App\\Http\\Controllers\\Api\\V2\\ProductController;
use App\\Http\\Controllers\\Api\\V2\\OrderController;
use Illuminate\\Support\\Facades\\Route;
Route::prefix('v2')->group(function () {
Route::apiResource('users', UserController::class);
Route::apiResource('products', ProductController::class);
Route::apiResource('orders', OrderController::class);
});
Register routes di bootstrap/app.php.
<?php
use Illuminate\\Foundation\\Application;
use Illuminate\\Foundation\\Configuration\\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
then: function () {
Route::middleware('api')
->prefix('api')
->group(base_path('routes/api_v1.php'));
Route::middleware('api')
->prefix('api')
->group(base_path('routes/api_v2.php'));
},
)
->create();
Sekarang buat controllers dengan response structure berbeda per versi. Contoh perubahan: V1 mengembalikan name sebagai single field, V2 memecah menjadi first_name dan last_name.
<?php
namespace App\\Http\\Controllers\\Api\\V1;
use App\\Http\\Controllers\\Controller;
use App\\Models\\User;
use Illuminate\\Http\\JsonResponse;
class UserController extends Controller
{
public function index(): JsonResponse
{
$users = User::paginate(20);
return response()->json([
'success' => true,
'data' => $users->map(fn ($user) => [
'id' => $user->id,
'name' => $user->name, // V1: single name field
'email' => $user->email,
'created_at' => $user->created_at->toISOString(),
]),
'meta' => [
'current_page' => $users->currentPage(),
'total_pages' => $users->lastPage(),
'total_items' => $users->total(),
],
]);
}
public function show(User $user): JsonResponse
{
return response()->json([
'success' => true,
'data' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'phone' => $user->phone,
'created_at' => $user->created_at->toISOString(),
],
]);
}
}
<?php
namespace App\\Http\\Controllers\\Api\\V2;
use App\\Http\\Controllers\\Controller;
use App\\Models\\User;
use Illuminate\\Http\\JsonResponse;
class UserController extends Controller
{
public function index(): JsonResponse
{
$users = User::paginate(20);
return response()->json([
'success' => true,
'data' => $users->map(fn ($user) => [
'id' => $user->id,
'first_name' => $user->first_name, // V2: split name
'last_name' => $user->last_name,
'email' => $user->email,
'avatar_url' => $user->avatar_url, // V2: new field
'created_at' => $user->created_at->toISOString(),
]),
'meta' => [
'current_page' => $users->currentPage(),
'total_pages' => $users->lastPage(),
'total_items' => $users->total(),
'api_version' => 'v2', // V2: include version info
],
]);
}
public function show(User $user): JsonResponse
{
return response()->json([
'success' => true,
'data' => [
'id' => $user->id,
'first_name' => $user->first_name,
'last_name' => $user->last_name,
'full_name' => $user->full_name,
'email' => $user->email,
'phone' => $user->phone,
'avatar_url' => $user->avatar_url,
'email_verified' => $user->hasVerifiedEmail(),
'created_at' => $user->created_at->toISOString(),
'updated_at' => $user->updated_at->toISOString(),
],
]);
}
}
Untuk menghindari duplikasi logic, gunakan shared services atau traits.
<?php
namespace App\\Services;
use App\\Models\\User;
use Illuminate\\Pagination\\LengthAwarePaginator;
class UserService
{
public function getPaginatedUsers(int $perPage = 20): LengthAwarePaginator
{
return User::query()
->with('roles')
->orderBy('created_at', 'desc')
->paginate($perPage);
}
public function createUser(array $data): User
{
return User::create([
'first_name' => $data['first_name'],
'last_name' => $data['last_name'],
'name' => $data['first_name'] . ' ' . $data['last_name'], // Keep for V1 compat
'email' => $data['email'],
'password' => bcrypt($data['password']),
]);
}
}
Buat API Resources untuk transform response secara konsisten.
<?php
namespace App\\Http\\Resources\\V1;
use Illuminate\\Http\\Resources\\Json\\JsonResource;
class UserResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'created_at' => $this->created_at->toISOString(),
];
}
}
<?php
namespace App\\Http\\Resources\\V2;
use Illuminate\\Http\\Resources\\Json\\JsonResource;
class UserResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'first_name' => $this->first_name,
'last_name' => $this->last_name,
'full_name' => $this->full_name,
'email' => $this->email,
'avatar_url' => $this->avatar_url,
'email_verified' => $this->hasVerifiedEmail(),
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
];
}
}
Untuk deprecation notice, buat middleware yang menambahkan header warning.
<?php
namespace App\\Http\\Middleware;
use Closure;
use Illuminate\\Http\\Request;
class ApiDeprecationWarning
{
public function handle(Request $request, Closure $next, string $message = '')
{
$response = $next($request);
$response->headers->set('X-API-Deprecated', 'true');
$response->headers->set('X-API-Deprecation-Message', $message ?: 'This API version is deprecated. Please migrate to v2.');
$response->headers->set('X-API-Sunset-Date', '2025-06-01');
return $response;
}
}
Terapkan ke routes V1.
<?php
// routes/api_v1.php
Route::prefix('v1')
->middleware('api.deprecated:Please upgrade to API v2 before June 2025')
->group(function () {
Route::apiResource('users', UserController::class);
});
Client yang well-maintained akan membaca header ini dan mulai planning migration.
Untuk dokumentasi, buat endpoint yang menjelaskan perbedaan antar versi.
<?php
namespace App\\Http\\Controllers\\Api;
use App\\Http\\Controllers\\Controller;
use Illuminate\\Http\\JsonResponse;
class ApiInfoController extends Controller
{
public function versions(): JsonResponse
{
return response()->json([
'current_version' => 'v2',
'supported_versions' => ['v1', 'v2'],
'deprecated_versions' => ['v1'],
'versions' => [
'v1' => [
'status' => 'deprecated',
'sunset_date' => '2025-06-01',
'documentation' => url('/docs/api/v1'),
],
'v2' => [
'status' => 'stable',
'released_at' => '2024-01-15',
'documentation' => url('/docs/api/v2'),
'changelog' => [
'Split name into first_name and last_name',
'Added avatar_url field',
'Added email_verified field',
'Improved pagination metadata',
],
],
],
]);
}
}
API versioning yang baik memberikan confidence kepada consumer bahwa mereka bisa bergantung pada API kalian. Breaking changes akan selalu di-announce dan ada waktu yang cukup untuk migration. Ini adalah tanda API yang mature dan professional.
Di bagian selanjutnya, kita akan membahas Exception Handling untuk menangani error dengan elegan dan informatif.
Bagian 10: Exception Handling - Error Management yang Professional
Exception Handling adalah cara menangani error dengan elegan, memberikan response yang informatif ke user tanpa membocorkan informasi sensitif tentang sistem internal. Skill ini membedakan developer amateur dengan professional karena error handling yang buruk bukan hanya bad UX tapi juga security risk. Developer yang paham exception handling bisa membangun aplikasi yang graceful under failure dan mudah di-debug.
Saya pernah menemukan aplikasi production yang menampilkan pesan "SQLSTATE[42S22]: Column not found: 1054 Unknown column 'pasword' in 'where clause'" langsung ke user. Selain membingungkan user, ini juga memberi petunjuk ke attacker tentang struktur database. Dengan proper exception handling, error seperti ini akan ditangkap dan dikonversi menjadi pesan yang user-friendly.
Mulai dengan membuat custom exceptions yang meaningful untuk domain aplikasi.
php artisan make:exception InsufficientBalanceException
php artisan make:exception OrderAlreadyProcessedException
php artisan make:exception InvalidCouponException
<?php
namespace App\\Exceptions;
use Exception;
class InsufficientBalanceException extends Exception
{
public function __construct(
public float $currentBalance,
public float $requiredAmount
) {
$shortage = $requiredAmount - $currentBalance;
parent::__construct("Saldo tidak mencukupi. Kekurangan: Rp " . number_format($shortage, 0, ',', '.'));
}
public function render($request)
{
if ($request->expectsJson()) {
return response()->json([
'success' => false,
'message' => $this->getMessage(),
'error_code' => 'INSUFFICIENT_BALANCE',
'data' => [
'current_balance' => $this->currentBalance,
'required_amount' => $this->requiredAmount,
'shortage' => $this->requiredAmount - $this->currentBalance,
],
], 422);
}
return back()->withErrors(['balance' => $this->getMessage()]);
}
}
<?php
namespace App\\Exceptions;
use Exception;
class OrderAlreadyProcessedException extends Exception
{
public function __construct(
public string $orderId,
public string $currentStatus
) {
parent::__construct("Order #{$orderId} sudah dalam status '{$currentStatus}' dan tidak bisa diproses ulang.");
}
public function render($request)
{
if ($request->expectsJson()) {
return response()->json([
'success' => false,
'message' => $this->getMessage(),
'error_code' => 'ORDER_ALREADY_PROCESSED',
'data' => [
'order_id' => $this->orderId,
'current_status' => $this->currentStatus,
],
], 409); // Conflict
}
return back()->withErrors(['order' => $this->getMessage()]);
}
}
<?php
namespace App\\Exceptions;
use Exception;
class InvalidCouponException extends Exception
{
public function __construct(
public string $couponCode,
public string $reason
) {
parent::__construct("Kupon '{$couponCode}' tidak valid: {$reason}");
}
public function render($request)
{
if ($request->expectsJson()) {
return response()->json([
'success' => false,
'message' => $this->getMessage(),
'error_code' => 'INVALID_COUPON',
'data' => [
'coupon_code' => $this->couponCode,
'reason' => $this->reason,
],
], 422);
}
return back()->withErrors(['coupon' => $this->getMessage()]);
}
}
Gunakan exceptions di service atau controller.
<?php
namespace App\\Services;
use App\\Exceptions\\InsufficientBalanceException;
use App\\Exceptions\\InvalidCouponException;
use App\\Models\\Order;
use App\\Models\\User;
use App\\Models\\Coupon;
class CheckoutService
{
public function processCheckout(User $user, array $items, ?string $couponCode = null): Order
{
$subtotal = collect($items)->sum(fn ($item) => $item['price'] * $item['quantity']);
$discount = 0;
if ($couponCode) {
$discount = $this->applyCoupon($couponCode, $subtotal);
}
$total = $subtotal - $discount;
if ($user->wallet_balance < $total) {
throw new InsufficientBalanceException(
currentBalance: $user->wallet_balance,
requiredAmount: $total
);
}
// Process order...
return Order::create([
'user_id' => $user->id,
'subtotal' => $subtotal,
'discount' => $discount,
'total' => $total,
]);
}
private function applyCoupon(string $code, float $subtotal): float
{
$coupon = Coupon::where('code', $code)->first();
if (!$coupon) {
throw new InvalidCouponException($code, 'Kupon tidak ditemukan');
}
if ($coupon->expired_at < now()) {
throw new InvalidCouponException($code, 'Kupon sudah expired');
}
if ($coupon->used_count >= $coupon->max_usage) {
throw new InvalidCouponException($code, 'Kupon sudah mencapai batas penggunaan');
}
if ($subtotal < $coupon->min_purchase) {
throw new InvalidCouponException($code, "Minimal pembelian Rp " . number_format($coupon->min_purchase, 0, ',', '.'));
}
return $coupon->discount_amount;
}
}
Untuk global exception handling, konfigurasi di bootstrap/app.php.
<?php
use App\\Exceptions\\InsufficientBalanceException;
use App\\Exceptions\\InvalidCouponException;
use App\\Exceptions\\OrderAlreadyProcessedException;
use Illuminate\\Auth\\AuthenticationException;
use Illuminate\\Database\\Eloquent\\ModelNotFoundException;
use Illuminate\\Foundation\\Application;
use Illuminate\\Foundation\\Configuration\\Exceptions;
use Illuminate\\Http\\Request;
use Illuminate\\Validation\\ValidationException;
use Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;
return Application::configure(basePath: dirname(__DIR__))
->withExceptions(function (Exceptions $exceptions) {
// Handle 404 untuk API
$exceptions->render(function (NotFoundHttpException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json([
'success' => false,
'message' => 'Resource tidak ditemukan.',
'error_code' => 'NOT_FOUND',
], 404);
}
});
// Handle Model Not Found
$exceptions->render(function (ModelNotFoundException $e, Request $request) {
if ($request->expectsJson()) {
$model = class_basename($e->getModel());
return response()->json([
'success' => false,
'message' => "{$model} tidak ditemukan.",
'error_code' => 'RESOURCE_NOT_FOUND',
], 404);
}
});
// Handle Authentication
$exceptions->render(function (AuthenticationException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json([
'success' => false,
'message' => 'Silakan login terlebih dahulu.',
'error_code' => 'UNAUTHENTICATED',
], 401);
}
});
// Handle Validation
$exceptions->render(function (ValidationException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json([
'success' => false,
'message' => 'Data yang dikirim tidak valid.',
'error_code' => 'VALIDATION_ERROR',
'errors' => $e->errors(),
], 422);
}
});
// Catch-all untuk unexpected errors
$exceptions->render(function (Throwable $e, Request $request) {
if ($request->expectsJson()) {
$isProduction = app()->environment('production');
return response()->json([
'success' => false,
'message' => $isProduction
? 'Terjadi kesalahan pada server.'
: $e->getMessage(),
'error_code' => 'SERVER_ERROR',
'debug' => $isProduction ? null : [
'exception' => get_class($e),
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => collect($e->getTrace())->take(5)->toArray(),
],
], 500);
}
});
// Report ke external service (Sentry, Bugsnag, dll)
$exceptions->report(function (Throwable $e) {
if (app()->bound('sentry')) {
app('sentry')->captureException($e);
}
});
})
->create();
Buat trait untuk konsisten API response di semua controller.
<?php
namespace App\\Traits;
trait ApiResponse
{
protected function successResponse($data, string $message = 'Success', int $code = 200)
{
return response()->json([
'success' => true,
'message' => $message,
'data' => $data,
], $code);
}
protected function errorResponse(string $message, string $errorCode, int $httpCode = 400, $data = null)
{
return response()->json([
'success' => false,
'message' => $message,
'error_code' => $errorCode,
'data' => $data,
], $httpCode);
}
protected function validationErrorResponse($errors)
{
return response()->json([
'success' => false,
'message' => 'Data yang dikirim tidak valid.',
'error_code' => 'VALIDATION_ERROR',
'errors' => $errors,
], 422);
}
}
Gunakan di controller dengan try-catch untuk handle exceptions dengan context tambahan.
<?php
namespace App\\Http\\Controllers\\Api;
use App\\Http\\Controllers\\Controller;
use App\\Services\\CheckoutService;
use App\\Traits\\ApiResponse;
use Illuminate\\Http\\Request;
class CheckoutController extends Controller
{
use ApiResponse;
public function __construct(
private CheckoutService $checkoutService
) {}
public function process(Request $request)
{
$validated = $request->validate([
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|exists:products,id',
'items.*.quantity' => 'required|integer|min:1',
'coupon_code' => 'nullable|string',
]);
try {
$order = $this->checkoutService->processCheckout(
user: $request->user(),
items: $validated['items'],
couponCode: $validated['coupon_code'] ?? null
);
return $this->successResponse(
data: $order->load('items'),
message: 'Checkout berhasil!',
code: 201
);
} catch (\\Exception $e) {
// Custom exceptions akan di-render oleh method render() masing-masing
// Unexpected exceptions akan di-catch oleh global handler
throw $e;
}
}
}
Proper exception handling membuat aplikasi lebih robust dan mudah di-debug. Error messages yang konsisten dan informatif membantu frontend developer dan API consumers memahami apa yang salah tanpa harus bertanya ke backend developer.
Di bagian selanjutnya, kita akan membahas Testing untuk membangun confidence bahwa kode yang kita tulis bekerja dengan benar.
Bagian 11: Testing - Membangun Kepercayaan dengan Automated Tests
Testing adalah kemampuan menulis automated tests untuk memastikan kode bekerja sesuai ekspektasi dan tetap bekerja setelah perubahan di masa depan. Skill ini paling membedakan developer profesional dengan yang tidak karena perusahaan serius tidak akan merge code tanpa test. Developer yang menulis test menunjukkan bahwa mereka peduli dengan kualitas, maintainability, dan tidak hanya fokus pada "yang penting jalan".
Banyak developer menganggap testing membuang waktu karena harus menulis kode tambahan. Tapi di project jangka panjang, test justru menghemat waktu. Tanpa test, setiap perubahan kecil bisa menyebabkan bug di tempat lain yang tidak terdeteksi sampai production. Dengan test, kalian bisa refactor dengan confidence karena test akan memberitahu jika ada yang rusak.
Laravel menggunakan PHPUnit dan menyediakan helper methods yang memudahkan testing. Ada dua jenis test utama: Unit Test untuk test class atau function secara isolated, dan Feature Test untuk test endpoint atau flow secara end-to-end.
Buat Unit Test untuk service class.
php artisan make:test Services/PriceCalculatorTest --unit
<?php
namespace Tests\\Unit\\Services;
use App\\Services\\PriceCalculator;
use PHPUnit\\Framework\\TestCase;
class PriceCalculatorTest extends TestCase
{
private PriceCalculator $calculator;
protected function setUp(): void
{
parent::setUp();
$this->calculator = new PriceCalculator();
}
public function test_calculate_subtotal_correctly(): void
{
$items = [
['price' => 100000, 'quantity' => 2],
['price' => 50000, 'quantity' => 3],
];
$subtotal = $this->calculator->calculateSubtotal($items);
$this->assertEquals(350000, $subtotal);
}
public function test_apply_percentage_discount(): void
{
$subtotal = 500000;
$discountPercent = 10;
$total = $this->calculator->applyDiscount($subtotal, $discountPercent);
$this->assertEquals(450000, $total);
}
public function test_calculate_tax(): void
{
$amount = 1000000;
$taxRate = 11; // PPN 11%
$tax = $this->calculator->calculateTax($amount, $taxRate);
$this->assertEquals(110000, $tax);
}
public function test_zero_quantity_returns_zero(): void
{
$items = [
['price' => 100000, 'quantity' => 0],
];
$subtotal = $this->calculator->calculateSubtotal($items);
$this->assertEquals(0, $subtotal);
}
}
Buat Feature Test untuk API endpoints.
php artisan make:test Api/AuthenticationTest
<?php
namespace Tests\\Feature\\Api;
use App\\Models\\User;
use Illuminate\\Foundation\\Testing\\RefreshDatabase;
use Tests\\TestCase;
class AuthenticationTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_register_with_valid_data(): void
{
$response = $this->postJson('/api/v1/register', [
'name' => 'John Doe',
'email' => '[email protected]',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response->assertStatus(201)
->assertJson([
'success' => true,
'message' => 'Registrasi berhasil!',
])
->assertJsonStructure([
'success',
'message',
'data' => ['id', 'name', 'email'],
]);
$this->assertDatabaseHas('users', [
'email' => '[email protected]',
]);
}
public function test_registration_fails_with_duplicate_email(): void
{
User::factory()->create(['email' => '[email protected]']);
$response = $this->postJson('/api/v1/register', [
'name' => 'John Doe',
'email' => '[email protected]',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['email']);
}
public function test_user_can_login_with_valid_credentials(): void
{
$user = User::factory()->create([
'password' => bcrypt('password123'),
]);
$response = $this->postJson('/api/v1/login', [
'email' => $user->email,
'password' => 'password123',
]);
$response->assertStatus(200)
->assertJson(['success' => true])
->assertJsonStructure([
'success',
'data' => ['token'],
]);
}
public function test_login_fails_with_invalid_credentials(): void
{
$user = User::factory()->create();
$response = $this->postJson('/api/v1/login', [
'email' => $user->email,
'password' => 'wrongpassword',
]);
$response->assertStatus(401)
->assertJson([
'success' => false,
'error_code' => 'INVALID_CREDENTIALS',
]);
}
public function test_authenticated_user_can_get_profile(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->getJson('/api/v1/profile');
$response->assertStatus(200)
->assertJson([
'success' => true,
'data' => [
'id' => $user->id,
'email' => $user->email,
],
]);
}
public function test_unauthenticated_user_cannot_access_profile(): void
{
$response = $this->getJson('/api/v1/profile');
$response->assertStatus(401);
}
}
Test untuk CRUD operations dengan authorization.
php artisan make:test Api/ProductTest
<?php
namespace Tests\\Feature\\Api;
use App\\Models\\Product;
use App\\Models\\User;
use App\\Models\\Role;
use Illuminate\\Foundation\\Testing\\RefreshDatabase;
use Tests\\TestCase;
class ProductTest extends TestCase
{
use RefreshDatabase;
private User $admin;
private User $staff;
protected function setUp(): void
{
parent::setUp();
// Setup roles
$adminRole = Role::create(['name' => 'admin', 'display_name' => 'Admin']);
$staffRole = Role::create(['name' => 'staff', 'display_name' => 'Staff']);
$this->admin = User::factory()->create();
$this->admin->roles()->attach($adminRole);
$this->staff = User::factory()->create();
$this->staff->roles()->attach($staffRole);
}
public function test_can_list_products(): void
{
Product::factory()->count(5)->create();
$response = $this->actingAs($this->staff)
->getJson('/api/v1/products');
$response->assertStatus(200)
->assertJsonCount(5, 'data')
->assertJsonStructure([
'success',
'data' => [
'*' => ['id', 'name', 'price', 'stock'],
],
]);
}
public function test_admin_can_create_product(): void
{
$productData = [
'name' => 'New Product',
'description' => 'Product description',
'price' => 150000,
'stock' => 100,
];
$response = $this->actingAs($this->admin)
->postJson('/api/v1/products', $productData);
$response->assertStatus(201)
->assertJson([
'success' => true,
'data' => [
'name' => 'New Product',
'price' => 150000,
],
]);
$this->assertDatabaseHas('products', ['name' => 'New Product']);
}
public function test_staff_cannot_delete_product(): void
{
$product = Product::factory()->create();
$response = $this->actingAs($this->staff)
->deleteJson("/api/v1/products/{$product->id}");
$response->assertStatus(403);
$this->assertDatabaseHas('products', ['id' => $product->id]);
}
public function test_admin_can_delete_product(): void
{
$product = Product::factory()->create();
$response = $this->actingAs($this->admin)
->deleteJson("/api/v1/products/{$product->id}");
$response->assertStatus(200);
$this->assertDatabaseMissing('products', ['id' => $product->id]);
}
public function test_create_product_validates_required_fields(): void
{
$response = $this->actingAs($this->admin)
->postJson('/api/v1/products', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['name', 'price']);
}
}
Gunakan Mocking untuk test yang melibatkan external services.
<?php
namespace Tests\\Feature\\Api;
use App\\Models\\Order;
use App\\Models\\User;
use App\\Services\\PaymentGateway;
use Illuminate\\Foundation\\Testing\\RefreshDatabase;
use Illuminate\\Support\\Facades\\Mail;
use Illuminate\\Support\\Facades\\Queue;
use App\\Mail\\OrderConfirmation;
use App\\Jobs\\ProcessPayment;
use Tests\\TestCase;
class CheckoutTest extends TestCase
{
use RefreshDatabase;
public function test_successful_checkout_queues_payment_job(): void
{
Queue::fake();
$user = User::factory()->create(['wallet_balance' => 500000]);
$order = Order::factory()->create([
'user_id' => $user->id,
'total' => 100000,
]);
$response = $this->actingAs($user)
->postJson("/api/v1/orders/{$order->id}/pay");
$response->assertStatus(200);
Queue::assertPushed(ProcessPayment::class, function ($job) use ($order) {
return $job->order->id === $order->id;
});
}
public function test_successful_checkout_sends_confirmation_email(): void
{
Mail::fake();
$user = User::factory()->create(['wallet_balance' => 500000]);
$response = $this->actingAs($user)
->postJson('/api/v1/checkout', [
'items' => [
['product_id' => 1, 'quantity' => 2],
],
]);
Mail::assertSent(OrderConfirmation::class, function ($mail) use ($user) {
return $mail->hasTo($user->email);
});
}
public function test_checkout_fails_with_insufficient_balance(): void
{
$user = User::factory()->create(['wallet_balance' => 10000]);
$response = $this->actingAs($user)
->postJson('/api/v1/checkout', [
'items' => [
['product_id' => 1, 'quantity' => 10], // Assume total > 10000
],
]);
$response->assertStatus(422)
->assertJson([
'success' => false,
'error_code' => 'INSUFFICIENT_BALANCE',
]);
}
public function test_payment_gateway_integration(): void
{
// Mock external payment gateway
$this->mock(PaymentGateway::class, function ($mock) {
$mock->shouldReceive('charge')
->once()
->andReturn((object) [
'success' => true,
'transaction_id' => 'TXN123456',
]);
});
$user = User::factory()->create();
$order = Order::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)
->postJson("/api/v1/orders/{$order->id}/pay");
$response->assertStatus(200)
->assertJson([
'success' => true,
'data' => [
'transaction_id' => 'TXN123456',
],
]);
}
}
Jalankan tests dengan perintah berikut.
# Jalankan semua tests
php artisan test
# Jalankan test spesifik
php artisan test --filter=AuthenticationTest
# Jalankan dengan coverage report
php artisan test --coverage
# Jalankan parallel untuk lebih cepat
php artisan test --parallel
Tips untuk testing yang efektif: test behavior bukan implementation, satu test untuk satu scenario, gunakan nama test yang deskriptif, dan jangan test framework Laravel itu sendiri. Fokus pada business logic dan edge cases yang spesifik untuk aplikasi kalian.
Di bagian terakhir, kita akan merangkum semua skill dan memberikan action plan untuk mulai belajar dan menerapkannya.
Bagian 12: Penutup - Action Plan untuk Meningkatkan Skill dan Karir
Kita sudah membahas 10 skill Laravel yang membedakan developer biasa dengan developer yang dibayar mahal. Mulai dari Rate Limiting untuk melindungi API, RBAC untuk sistem permission yang fleksibel, Raw SQL dan Query Optimization untuk performa maksimal, Job Queue dan Events untuk arsitektur yang scalable, Custom Commands untuk automation, API Versioning untuk backward compatibility, Exception Handling untuk error management yang profesional, hingga Testing untuk membangun confidence pada kode yang kita tulis.
Skill-skill ini bukan sekadar nice-to-have. Ini adalah requirement yang sering ditanyakan saat technical interview di perusahaan top. Ini adalah kemampuan yang membuat client percaya untuk memberikan project besar. Dan ini adalah pembeda yang menentukan apakah gaji kalian stuck di angka yang sama atau terus naik seiring bertambahnya value yang kalian berikan.
Saya tidak menyarankan kalian untuk belajar semua skill ini sekaligus. Itu overwhelming dan tidak efektif. Lebih baik fokus pada satu skill per minggu atau per project. Mulai dari yang paling langsung applicable ke pekerjaan kalian saat ini.
Jika kalian bekerja dengan API, mulai dari Rate Limiting dan Exception Handling. Dua skill ini bisa langsung diterapkan ke project apapun dan memberikan improvement yang terasa. Selanjutnya pelajari API Versioning kalau API kalian sudah digunakan oleh multiple clients.
Jika kalian sering berurusan dengan aplikasi yang lambat, fokus ke Query Optimization dan Raw SQL. Pelajari cara membaca EXPLAIN, identifikasi N+1 problem, dan kapan harus turun ke level SQL untuk performa maksimal.
Jika kalian membangun aplikasi dengan user roles yang kompleks, RBAC adalah prioritas. Implementasikan sistem permission yang proper dari awal agar tidak perlu refactor besar di kemudian hari.
Untuk semua project, Testing dan Job Queue adalah skill yang selalu berguna. Testing memberikan confidence untuk refactor dan menambah fitur baru. Queue memastikan aplikasi tetap responsif meskipun ada task berat yang harus dijalankan.
Setelah menguasai skill-skill ini, kalian akan menemukan bahwa interview jadi lebih mudah karena kalian bisa menjawab pertanyaan teknis dengan contoh implementasi nyata. Client lebih percaya karena kalian bisa menjelaskan bagaimana aplikasi yang kalian bangun akan handle berbagai edge cases. Dan yang paling penting, kalian sendiri lebih confident karena tahu bahwa kode yang kalian tulis adalah production-ready, bukan sekadar "yang penting jalan".
Belajar Laravel Lebih Dalam di BuildWithAngga
Kalau kalian serius ingin mengembangkan karir sebagai Laravel developer profesional, saya mengajak kalian untuk bergabung dengan BuildWithAngga. Di platform kami, kalian tidak hanya belajar teori tapi langsung praktek membangun project nyata yang bisa dimasukkan ke portfolio.
Beberapa kelas Laravel dengan studi kasus menarik yang bisa kalian pelajari:
Membangun E-Commerce Platform dari Nol adalah kelas yang mengajarkan cara membangun toko online lengkap dengan fitur product catalog, shopping cart, payment gateway integration, order management, dan admin dashboard. Kalian akan belajar implementasi RBAC untuk membedakan akses customer, seller, dan admin. Queue digunakan untuk proses payment dan notification. Testing untuk memastikan checkout flow berjalan dengan benar di berbagai skenario.
Membangun REST API untuk Mobile App fokus pada pembuatan backend yang robust untuk aplikasi mobile. Kalian akan implementasi authentication dengan Sanctum, API versioning untuk support multiple app versions, rate limiting untuk protect dari abuse, dan proper exception handling dengan response yang konsisten. Studi kasusnya adalah aplikasi delivery service seperti GoFood atau GrabFood.
Membangun SaaS Multi-tenant Application adalah kelas advanced yang mengajarkan arsitektur untuk aplikasi yang melayani banyak tenant/company dalam satu codebase. Kalian akan belajar database design untuk multi-tenancy, billing integration, subscription management, dan bagaimana scale aplikasi saat tenant bertambah. Ini adalah skill yang sangat dicari untuk posisi senior.
Membangun Real-time Dashboard dengan Laravel dan WebSocket mengajarkan cara membangun dashboard yang update secara real-time tanpa refresh. Studi kasusnya adalah monitoring dashboard untuk e-commerce yang menampilkan live orders, revenue, dan visitor statistics. Kalian akan belajar Laravel Echo, Pusher/Soketi, dan bagaimana optimize query untuk data yang constantly update.
Membangun Job Portal Platform adalah project yang mencakup hampir semua skill yang sudah kita bahas. Ada RBAC untuk company, job seeker, dan admin. Ada queue untuk process CV parsing dan email notification. Ada search optimization untuk filter ribuan job listings. Dan ada testing untuk memastikan application flow berjalan dengan benar.
Benefit bergabung dengan BuildWithAngga:
- Akses Selamanya - Sekali bayar, akses materi selamanya tanpa batas waktu. Update materi juga gratis.
- Project-Based Learning - Setiap kelas menghasilkan project nyata yang bisa langsung dimasukkan ke portfolio.
- Source Code Lengkap - Dapatkan starter code dan final code untuk setiap project sebagai referensi.
- Studi Kasus Relevan - Project yang diajarkan adalah aplikasi yang benar-benar dibutuhkan industri, bukan todo app atau blog sederhana.
- Update Teknologi Terbaru - Materi selalu di-update mengikuti versi Laravel terbaru dan best practices terkini.
- Mentor Berpengalaman - Tanya jawab langsung dengan praktisi yang sudah bertahun-tahun di industri.
- Komunitas Supportive - Gabung dengan ribuan developer Indonesia lainnya untuk diskusi dan networking.
- Sertifikat Completion - Dapatkan sertifikat yang bisa ditampilkan di LinkedIn atau CV.
- Career Support - Tips interview, review CV, dan guidance untuk mendapatkan pekerjaan yang lebih baik.
Mulai dari Sekarang
Perjalanan untuk menjadi developer yang dibayar mahal bukan sprint, tapi marathon. Tidak ada shortcut yang bisa membuat kalian mahir dalam seminggu. Yang ada adalah konsistensi belajar dan praktek setiap hari.
Pilih satu skill dari artikel ini yang paling relevan dengan pekerjaan kalian saat ini. Baca ulang bagian tersebut, pahami konsepnya, dan implementasikan di project kalian. Jangan hanya copy-paste kode, tapi pahami mengapa kode tersebut ditulis seperti itu.
Setelah berhasil implement satu skill, lanjut ke skill berikutnya. Dalam beberapa bulan, kalian akan menemukan bahwa kemampuan kalian sudah berbeda jauh dari sebelumnya. Interview yang tadinya terasa sulit jadi lebih mudah. Project yang tadinya terasa overwhelming jadi manageable. Dan kepercayaan diri kalian sebagai developer akan meningkat drastis.
Ingat, setiap expert dulunya adalah beginner. Yang membedakan adalah mereka tidak berhenti belajar dan tidak takut untuk mencoba hal baru. Skill-skill yang kita bahas mungkin terasa advanced sekarang, tapi dengan latihan yang konsisten, kalian akan menguasainya.
Saya percaya setiap developer Indonesia punya potensi untuk menjadi world-class engineer. Yang dibutuhkan adalah resource yang tepat, guidance yang benar, dan komunitas yang supportive. Itulah yang kami coba berikan di BuildWithAngga.
Terima kasih sudah membaca artikel ini sampai selesai. Saya harap pengetahuan yang dibagikan bisa memberikan value untuk karir kalian. Jika ada pertanyaan atau ingin diskusi lebih lanjut, jangan ragu untuk reach out.
Selamat belajar dan terus berkembang. Sampai jumpa di kelas!
Angga Risky Setiawan Founder, BuildWithAngga