Membangun antarmuka pemrograman aplikasi (REST API) yang siap dipakai di produksi bukan sekadar menulis rute dasar dan controller CRUD. Di lingkungan enterprise, backend dituntut menjaga integritas data melalui transaksi atomik, menghindari query berulang (N+1 problem), dan tetap responsif saat tabel tumbuh sampai jutaan baris tanpa membebani CPU server basis data.
Kombinasi framework Laravel (PHP modern) dan sistem basis data PostgreSQL adalah salah satu stack yang paling teruji untuk kebutuhan ini. Tutorial ini membedah arsitekturnya langkah demi langkah: prasyarat lingkungan, perintah Artisan, pemisahan Controller-Service, validasi Form Request, standarisasi API Resource, pengindeksan yang dibuktikan dengan EXPLAIN, cursor pagination, rate limiting, hingga pengujian otomatis.
- Pisahkan tanggung jawab dengan pola Controller-Service: controller tipis, logika bisnis dan transaksi di Service Layer. Repository baru diperlukan bila query kompleks dipakai ulang di banyak tempat.
- Bungkus operasi tulis multi-tabel di DB::transaction() dan kunci baris yang akan diubah dengan lockForUpdate() secara terurut untuk menjamin ACID tanpa deadlock.
- Rancang indeks berdasarkan query yang benar-benar dijalankan, lalu buktikan penggunaannya dengan EXPLAIN (ANALYZE, BUFFERS).
- Gunakan cursorPaginate() untuk daftar besar agar biaya query tidak membengkak di halaman yang dalam, dengan konsekuensi tidak ada total halaman.
- Standarkan keluaran dengan API Resource dan whenLoaded() agar struktur JSON konsisten, N+1 terhindar, dan kolom privat tidak bocor.
- Lindungi endpoint dengan Sanctum, rate limiting bertingkat, dan tes otomatis untuk skenario kritis seperti stok tidak cukup.
Prasyarat & Lingkungan Pengembangan (Prerequisites)
Sebelum memulai, pastikan lingkungan pengembangan lokal atau server Anda memenuhi spesifikasi berikut:
Framework: Laravel 13 (rilis Maret 2026) direkomendasikan untuk proyek baru. Laravel 12 masih menerima perbaikan keamanan hingga Februari 2027, tetapi perbaikan bug reguler sudah berakhir pada Agustus 2026.
PHP: Versi
8.3atau lebih baru untuk Laravel 13 (8.2untuk Laravel 12), dengan ekstensi aktif:pdo_pgsql,mbstring,bcmath,openssl, dantokenizer.Database: PostgreSQL 16 atau lebih baru (bisa lokal atau lewat container Docker).
Package Manager: Composer versi
2.2+.API Testing Client: Postman, Insomnia, atau ekstensi VS Code Thunder Client untuk menguji endpoint HTTP.
Pengetahuan Dasar: Object-Oriented Programming (OOP) PHP, dependency injection, dan konsep REST API (HTTP methods dan status codes).
Pemeriksaan Ekstensi pdo_pgsql
PostgreSQL 14 Segera Mencapai End of Life
Daftar Perintah CLI & Artisan Generator
Laravel menyediakan perintah bawaan (Artisan Console) untuk men-generate class dengan namespace dan struktur standar. Perhatikan langkah install:api: sejak Laravel 11, file routes/api.php tidak ada secara default dan perintah ini membuatnya sekaligus memasang Sanctum.
1# 1. Buat proyek dan aktifkan API routing (membuat routes/api.php dan memasang Sanctum)2composer create-project laravel/laravel:^13.0 arkatek-orders-api3cd arkatek-orders-api4php artisan install:api5 6# 2. Konfigurasi koneksi PostgreSQL di file .env7# DB_CONNECTION=pgsql8# DB_HOST=127.0.0.19# DB_PORT=543210# DB_DATABASE=arkatek_production11# DB_USERNAME=postgres12# DB_PASSWORD=secret13 14# 3. Model, migration, dan factory15php artisan make:model Customer -mf16php artisan make:model Product -mf17php artisan make:model Order -m18php artisan make:model OrderItem -m19 20# 4. Form Request untuk validasi payload API21php artisan make:request StoreOrderRequest22 23# 5. Service Layer dan exception domain24mkdir -p app/Services25touch app/Services/OrderService.php26php artisan make:exception InsufficientStockException27 28# 6. API Resource untuk kontrak JSON29php artisan make:resource OrderResource30 31# 7. API Controller (hanya rute index dan store yang dipakai)32php artisan make:controller Api/OrderController --api33 34# 8. Migrasi tabel dan indeks ke PostgreSQL, lalu jalankan server35php artisan migrate36php artisan serve
Migrasi customers, products, dan order_items mengikuti pola yang sama dengan kolom minimal berikut:
customers:
name,email.products:
name,price(decimal(12,2)),stock(unsignedInteger).order_items:
order_id(cascade on delete),product_id(restrict on delete),quantity,unit_price,subtotal.
Model Order membutuhkan $fillable dan relasi. Model OrderItem membutuhkan $fillable untuk product_id, quantity, unit_price, subtotal serta relasi product(), karena createMany() memakai mass assignment.
1namespace App\Models;2 3use Illuminate\Database\Eloquent\Model;4use Illuminate\Database\Eloquent\Relations\BelongsTo;5use Illuminate\Database\Eloquent\Relations\HasMany;6 7class Order extends Model8{9 protected $fillable = ["user_id", "customer_id", "order_number", "total_amount", "status", "payment_method"];10 11 public function customer(): BelongsTo12 {13 return $this->belongsTo(Customer::class);14 }15 16 public function items(): HasMany17 {18 return $this->hasMany(OrderItem::class);19 }20}
Terakhir, definisikan rute versi 1 di routes/api.php. Middleware auth:sanctum dijalankan lebih dulu agar limiter di bagian rate limiting dapat mengenali pengguna:
1use App\Http\Controllers\Api\OrderController;2use Illuminate\Support\Facades\Route;3 4Route::prefix("v1")5 ->middleware(["auth:sanctum", "throttle:orders-api"])6 ->group(function () {7 Route::apiResource("orders", OrderController::class)->only(["index", "store"]);8 });
1. Skema Migrasi & Pengindeksan PostgreSQL
Performa database pada jutaan baris data dimulai dari indeks yang dirancang berdasarkan query yang benar-benar dijalankan. Tabel orders di bawah melayani dua pola akses: daftar pesanan berdasarkan status (endpoint listing) dan filter per pelanggan.
1use Illuminate\Database\Migrations\Migration;2use Illuminate\Database\Schema\Blueprint;3use Illuminate\Support\Facades\Schema;4 5return new class extends Migration6{7 public function up(): void8 {9 Schema::create("orders", function (Blueprint $table) {10 $table->id();11 $table->foreignId("user_id")->constrained()->restrictOnDelete();12 $table->foreignId("customer_id")->constrained()->restrictOnDelete();13 $table->string("order_number")->unique();14 $table->string("status"); // PENDING, PROCESSING, COMPLETED, CANCELLED15 $table->string("payment_method");16 $table->decimal("total_amount", 12, 2);17 $table->timestamps();18 19 // Endpoint listing: WHERE status = ? ORDER BY id DESC20 $table->index(["status", "id"]);21 22 // Filter per pelanggan: WHERE customer_id = ? AND status = ? ORDER BY created_at23 $table->index(["customer_id", "status", "created_at"]);24 });25 }26 27 public function down(): void28 {29 Schema::dropIfExists("orders");30 }31};
Ada dua hal penting di sini. Pertama, indeks komposit hanya efektif bila query memfilter kolom paling kiri (leftmost prefix). Indeks (customer_id, status, created_at) tidak membantu query WHERE status = ... ORDER BY id DESC karena customer_id tidak difilter, itu sebabnya endpoint listing memakai indeks (status, id) tersendiri. Kedua, PostgreSQL tidak membuat indeks otomatis untuk kolom foreign key, sehingga indeks komposit berawalan customer_id juga melayani pencarian per pelanggan. Untuk data transaksi, gunakan restrictOnDelete() agar riwayat pesanan tidak ikut terhapus saat user atau pelanggan dihapus.
Buktikan dengan EXPLAIN (ANALYZE, BUFFERS)
Jangan menebak apakah indeks dipakai. Jalankan EXPLAIN pada database uji berisi beberapa juta baris, lalu bandingkan sebelum dan sesudah indeks dibuat. Cari Index Scan (bukan Seq Scan) serta perhatikan Execution Time dan jumlah buffer yang dibaca. Query dibatasi 21 baris karena cursorPaginate(20) mengambil satu baris ekstra untuk mengetahui apakah masih ada halaman berikutnya.
1EXPLAIN (ANALYZE, BUFFERS)2SELECT * FROM orders3WHERE status = 'PENDING'4ORDER BY id DESC5LIMIT 21;
Untuk teknik diagnosis query lambat yang lebih luas, baca Strategi Optimasi Database Skala Besar.
2. Implementasi Lapisan Validasi: Form Request
Validasi ketat adalah benteng pertahanan pertama API. Buat Form Request khusus untuk memvalidasi pembuatan pesanan, termasuk batas jumlah item agar payload tidak bisa dibuat sangat besar:
1namespace App\Http\Requests;2 3use Illuminate\Foundation\Http\FormRequest;4use Illuminate\Validation\Rule;5 6class StoreOrderRequest extends FormRequest7{8 public function authorize(): bool9 {10 return true; // otorisasi rute ditangani middleware auth:sanctum11 }12 13 public function rules(): array14 {15 return [16 "customer_id" => ["required", "integer", "exists:customers,id"],17 "items" => ["required", "array", "min:1", "max:50"],18 "items.*.product_id" => ["required", "integer", "exists:products,id"],19 "items.*.quantity" => ["required", "integer", "min:1", "max:100"],20 "payment_method" => ["required", "string", Rule::in(["BANK_TRANSFER", "E_WALLET", "CREDIT_CARD"])],21 ];22 }23}
3. Implementasi Service Layer: Transaksi Atomik & Penguncian Baris
Seluruh logika bisnis yang memotong stok dan menghitung harga wajib dibungkus dalam transaksi atomik (DB::transaction) dengan penguncian baris (lockForUpdate). Dua detail penting: semua produk dikunci sekaligus dalam satu query yang diurutkan berdasarkan id (menghindari deadlock dan query per item), dan perhitungan uang memakai bcmath agar tidak kehilangan presisi.
1namespace App\Services;2 3use App\Exceptions\InsufficientStockException;4use App\Models\Order;5use App\Models\Product;6use Illuminate\Support\Facades\DB;7use Illuminate\Support\Str;8 9class OrderService10{11 public function createOrder(int $userId, array $data): Order12 {13 return DB::transaction(function () use ($userId, $data) {14 // Gabungkan item yang product_id-nya sama15 $quantities = collect($data["items"])16 ->groupBy("product_id")17 ->map(fn ($rows) => $rows->sum("quantity"));18 19 // Kunci semua produk sekaligus, terurut berdasarkan id agar tidak deadlock20 $products = Product::whereIn("id", $quantities->keys())21 ->orderBy("id")22 ->lockForUpdate()23 ->get()24 ->keyBy("id");25 26 $totalAmount = "0.00";27 $orderItems = [];28 29 foreach ($quantities as $productId => $quantity) {30 $product = $products[$productId];31 32 if ($product->stock < $quantity) {33 throw new InsufficientStockException($product->name);34 }35 36 $product->decrement("stock", $quantity);37 38 $subtotal = bcmul($product->price, (string) $quantity, 2);39 $totalAmount = bcadd($totalAmount, $subtotal, 2);40 41 $orderItems[] = [42 "product_id" => $product->id,43 "quantity" => $quantity,44 "unit_price" => $product->price,45 "subtotal" => $subtotal,46 ];47 }48 49 $order = Order::create([50 "user_id" => $userId,51 "customer_id" => $data["customer_id"],52 "order_number" => "ORD-" . Str::ulid(),53 "total_amount" => $totalAmount,54 "status" => "PENDING",55 "payment_method" => $data["payment_method"],56 ]);57 58 $order->items()->createMany($orderItems);59 60 // Eager loading relasi untuk respons, tanpa query tambahan per item61 return $order->load(["customer", "items.product"]);62 });63 }64}
Kunci Baris Sekaligus dan Terurut
Kekurangan stok dilempar sebagai exception domain yang merender dirinya sendiri menjadi respons 422, bukan error 500 generik. Transaksi otomatis di-rollback sehingga stok tidak berubah.
1namespace App\Exceptions;2 3use Exception;4use Illuminate\Http\JsonResponse;5 6class InsufficientStockException extends Exception7{8 public function __construct(public readonly string $productName)9 {10 parent::__construct("Stok produk {$productName} tidak mencukupi.");11 }12 13 public function render(): JsonResponse14 {15 return response()->json(["message" => $this->getMessage()], 422);16 }17}
4. Optimasi Database: Cursor Pagination
Ketika tabel transaksi mencapai jutaan baris, pagination berbasis offset (LIMIT 20 OFFSET 500000) memaksa database memindai lalu membuang ratusan ribu baris sebelum mengembalikan 20 baris terakhir. Biayanya naik seiring halaman yang makin dalam, dan ini penyebab umum lonjakan CPU pada server database.
Biaya Query per Halaman
Dengan kolom urut yang terindeks, halaman pertama dan halaman ke-100.000 diproses dengan biaya yang hampir sama.
Berikut Controller ramping yang menyajikan data dengan cursorPaginate() dan mengeksekusi eager loading di sisi query:
1namespace App\Http\Controllers\Api;2 3use App\Http\Controllers\Controller;4use App\Http\Requests\StoreOrderRequest;5use App\Http\Resources\OrderResource;6use App\Models\Order;7use App\Services\OrderService;8use Illuminate\Http\JsonResponse;9use Illuminate\Http\Resources\Json\AnonymousResourceCollection;10 11class OrderController extends Controller12{13 public function __construct(private readonly OrderService $orderService)14 {15 }16 17 // GET /api/v1/orders18 public function index(): AnonymousResourceCollection19 {20 $orders = Order::with(["customer", "items.product"])21 ->where("status", "PENDING")22 ->orderByDesc("id")23 ->cursorPaginate(20);24 25 return OrderResource::collection($orders);26 }27 28 // POST /api/v1/orders29 public function store(StoreOrderRequest $request): JsonResponse30 {31 $order = $this->orderService->createOrder(32 $request->user()->id,33 $request->validated()34 );35 36 return (new OrderResource($order))->response()->setStatusCode(201);37 }38}
Kedua endpoint kini memakai envelope bawaan Laravel (data, plus links dan meta pada koleksi), sehingga struktur respons konsisten bagi tim frontend dan mobile.
Cursor Pagination vs Offset Pagination
- Biaya query tidak bertambah seiring dalamnya halaman, selama kolom urut terindeks.
- Hasil stabil saat data baru masuk: tidak ada baris duplikat atau terlewat antar halaman.
- Cocok untuk infinite scroll dan aplikasi mobile.
- Tidak ada total data atau total halaman, dan tidak bisa melompat langsung ke halaman tertentu.
- Kolom pengurut harus terindeks dan unik, atau ditambah pemecah seri seperti id.
- Cursor bergantung pada urutan yang dipakai, sehingga mengganti urutan membuat cursor lama tidak berlaku.
5. Menstandarisasi Respon dengan Eloquent API Resource
Bungkus data model dengan API Resource untuk menjamin kontrak JSON yang stabil bagi tim frontend maupun mobile, serta mencegah kebocoran kolom rahasia database. Dua penyesuaian penting: relasi dibungkus whenLoaded() agar tidak memicu lazy loading (N+1) bila lupa eager load, dan nilai uang dikembalikan sebagai string desimal, bukan float, agar presisinya terjaga.
1namespace App\Http\Resources;2 3use Illuminate\Http\Request;4use Illuminate\Http\Resources\Json\JsonResource;5 6class OrderResource extends JsonResource7{8 public function toArray(Request $request): array9 {10 return [11 "id" => $this->id,12 "order_number" => $this->order_number,13 "status" => $this->status,14 "total_amount" => $this->total_amount, // string desimal, mis. "150000.00"15 "payment_method" => $this->payment_method,16 "created_at" => $this->created_at?->toIso8601String(),17 "customer" => $this->whenLoaded("customer", fn () => [18 "id" => $this->customer->id,19 "name" => $this->customer->name,20 "email" => $this->customer->email,21 ]),22 "items" => $this->whenLoaded("items", fn () => $this->items->map(fn ($item) => [23 "product_id" => $item->product_id,24 "product_name" => $item->relationLoaded("product") ? $item->product?->name : null,25 "quantity" => $item->quantity,26 "unit_price" => $item->unit_price,27 "subtotal" => $item->subtotal,28 ])),29 ];30 }31}
Deteksi N+1 Sejak Development
6. Keamanan & Stabilitas: Dynamic Rate Limiting
Setiap endpoint harus dilindungi rate limiting untuk mencegah brute-force, scraping agresif, atau kehabisan koneksi basis data. Definisikan limiter bertingkat di AppServiceProvider:
1use Illuminate\Cache\RateLimiting\Limit;2use Illuminate\Http\Request;3use Illuminate\Support\Facades\RateLimiter;4 5public function boot(): void6{7 RateLimiter::for("orders-api", function (Request $request) {8 return $request->user()9 ? Limit::perMinute(300)->by($request->user()->id)10 : Limit::perMinute(60)->by($request->ip());11 });12}
Tiered Limiting: 300 request/menit per pengguna terautentikasi dan 60 request/menit per IP untuk pengguna publik. Karena middleware autentikasi dijalankan lebih dulu,
$request->user()sudah terisi saat limiter dievaluasi.Autentikasi: Sanctum menerbitkan token opaque (bukan JWT) dan cocok untuk API internal atau aplikasi mobile milik sendiri. Untuk skenario OAuth2 atau JWT, ikuti panduan Implementasi Autentikasi Modern (OAuth2 & JWT) pada Aplikasi Web.
Fondasi Infrastruktur: Jalankan backend Laravel di lingkungan yang aman sesuai Tutorial Setup Docker Compose Produksi.
7. Menguji API: Buktikan Transaksi Benar-Benar Rollback
Tes otomatis untuk skenario kritis lebih berharga daripada puluhan tes untuk jalur mulus. Tes berikut memastikan pesanan dengan stok tidak cukup ditolak dengan 422 dan stok produk tidak berubah. Isi definisi minimal di factory Customer dan Product terlebih dahulu, lalu jalankan php artisan test.
1namespace Tests\Feature;2 3use App\Models\Customer;4use App\Models\Product;5use App\Models\User;6use Illuminate\Foundation\Testing\RefreshDatabase;7use Laravel\Sanctum\Sanctum;8use Tests\TestCase;9 10class CreateOrderTest extends TestCase11{12 use RefreshDatabase;13 14 public function test_stok_tidak_cukup_menghasilkan_422_dan_stok_tidak_berubah(): void15 {16 Sanctum::actingAs(User::factory()->create());17 $customer = Customer::factory()->create();18 $product = Product::factory()->create(["stock" => 2]);19 20 $response = $this->postJson("/api/v1/orders", [21 "customer_id" => $customer->id,22 "items" => [["product_id" => $product->id, "quantity" => 5]],23 "payment_method" => "BANK_TRANSFER",24 ]);25 26 $response->assertStatus(422);27 $this->assertSame(2, $product->fresh()->stock);28 }29}
Butuh Pengembangan Backend API Skalabel dan Berkinerja Tinggi?
Konsultasikan rancangan arsitektur sistem, audit performa database, dan pengembangan software kustom bersama engineer Arkatek.
Pertanyaan Umum Seputar REST API Laravel dan PostgreSQL
Apakah Laravel memerlukan Repository pattern?
Untuk CRUD dan query sederhana, Eloquent sudah cukup. Repository berguna bila Anda ingin mengabstraksi sumber data atau query kompleks dipakai berulang di banyak tempat. Tutorial ini memakai Controller dan Service agar tetap ringkas.
Apa beda cursorPaginate() dan paginate()?
paginate() menyediakan total data dan nomor halaman, tetapi memakai OFFSET dan COUNT yang makin berat pada tabel besar. cursorPaginate() memakai kondisi berbasis kolom terakhir sehingga biayanya stabil, tetapi tanpa total halaman dan lompat halaman.
Bagaimana cara mencegah overselling stok?
Gunakan DB::transaction dengan lockForUpdate() seperti pada tutorial ini. Alternatifnya adalah pembaruan atomik UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ? lalu memeriksa jumlah baris yang terpengaruh.
Apakah perlu versioning API seperti /api/v1?
Ya, terutama bila API dikonsumsi aplikasi mobile atau pihak ketiga, karena klien lama tidak bisa dipaksa langsung memperbarui aplikasi. Prefix versi memberi ruang mengubah kontrak tanpa merusak klien yang ada.
Kapan memilih PostgreSQL untuk backend Laravel?
PostgreSQL dikenal kuat untuk beban transaksional yang kompleks, dengan tipe data dan opsi pengindeksan yang kaya seperti partial index dan JSONB. MySQL atau MariaDB juga didukung Laravel, jadi pilihlah sesuai kebutuhan proyek dan keahlian tim.
Kesimpulan
Membangun REST API yang siap di tingkat enterprise membutuhkan struktur kode modular, isolasi logika bisnis yang bersih, dan pemahaman tentang cara mesin basis data bekerja. Dengan Service Layer, transaksi atomik dengan penguncian terurut, indeks yang dibuktikan lewat EXPLAIN, cursor pagination, dan tes untuk skenario kritis, backend Anda punya fondasi yang stabil untuk tumbuh. Jika Anda masih menimbang bentuk arsitektur keseluruhan, baca juga Panduan Arsitektur Web Modern: Monolith vs Microservices.
Referensi resmi: Cursor Pagination dan Eloquent API Resources di dokumentasi Laravel, serta Using EXPLAIN di dokumentasi PostgreSQL.