Design Patterns dalam Laravel: Panduan Pragmatis

Ekosistem Laravel dipenuhi perdebatan soal pattern. Repository, Service, Observer, Strategy — semuanya terdengar bagus di atas kertas, tapi tidak semua layak diterapkan di setiap project. Artikel ini membahas pattern-pattern yang umum dipakai, kapan mereka memberikan nilai nyata, dan kapan mereka justru menambah beban tanpa manfaat.


Service Pattern

Service class adalah pattern paling umum dan paling tidak kontroversial di Laravel. Idenya sederhana: pindahkan business logic dari controller ke class tersendiri. Controller hanya bertanggung jawab menerima request, memanggil service, dan mengembalikan response.

// Tanpa Service — controller menanggung terlalu banyak
class OrderController extends Controller
{
    public function store(Request $request)
    {
        $validated = $request->validate([...]);

        $order = Order::create($validated);
        $order->items()->createMany($validated['items']);

        $stock = Product::find($validated['product_id']);
        $stock->decrement('quantity', $validated['qty']);

        Mail::to($request->user())->send(new OrderConfirmation($order));

        return response()->json($order, 201);
    }
}
// Dengan Service — controller jadi tipis dan jelas
class OrderController extends Controller
{
    public function __construct(protected OrderService $orderService) {}

    public function store(OrderRequest $request): JsonResponse
    {
        $order = $this->orderService->create($request->validated());
        return response()->json($order, 201);
    }
}
// OrderService.php
class OrderService
{
    public function create(array $data): Order
    {
        $order = Order::create($data);
        $order->items()->createMany($data['items']);

        Product::find($data['product_id'])->decrement('quantity', $data['qty']);

        Mail::to($order->user)->send(new OrderConfirmation($order));

        return $order;
    }
}

Gunakan ketika:

  • Satu aksi melibatkan lebih dari satu model atau lebih dari satu side effect
  • Logic yang sama dipanggil dari beberapa titik (controller, artisan command, job)
  • Kamu ingin logic mudah di-test tanpa menyentuh HTTP layer

Jangan gunakan ketika:

  • Method di service isinya hanya satu baris yang memanggil Eloquent secara langsung — itu bukan service, itu indirection yang tidak perlu

Repository Pattern

Repository adalah pattern yang paling sering diperdebatkan di komunitas Laravel. Secara teori, ia mengabstraksi data access layer sehingga sumber data bisa diganti tanpa mengubah business logic.

// Interface
interface UserRepositoryInterface
{
    public function findById(int $id): ?User;
    public function findActiveByRole(string $role): Collection;
    public function create(array $data): User;
}
// Implementasi Eloquent
class EloquentUserRepository implements UserRepositoryInterface
{
    public function findById(int $id): ?User
    {
        return User::find($id);
    }

    public function findActiveByRole(string $role): Collection
    {
        return User::active()->role($role)->get();
    }

    public function create(array $data): User
    {
        return User::create($data);
    }
}
// Binding di AppServiceProvider
$this->app->bind(UserRepositoryInterface::class, EloquentUserRepository::class);
// Digunakan di Service
class UserService
{
    public function __construct(protected UserRepositoryInterface $users) {}

    public function getAdmins(): Collection
    {
        return $this->users->findActiveByRole('admin');
    }
}

Masalah utama dengan Repository di Laravel:

Eloquent sudah mengimplementasikan Active Record, yang artinya model sudah tahu cara menyimpan dan mengambil datanya sendiri. Menambah Repository di atas Eloquent seringkali hanya menciptakan wrapper tipis tanpa nilai tambah. Lebih parah, banyak developer tetap mengembalikan Eloquent model dari repository mereka — yang berarti caller tetap bergantung pada Eloquent, sehingga tujuan abstraksi tidak tercapai sama sekali.

Gunakan ketika:

  • Project kamu menggunakan Doctrine (bukan Eloquent), di mana Repository memang bagian dari design-nya
  • Ada kemungkinan nyata (bukan spekulatif) untuk berpindah data source — misalnya sebagian data dari MySQL, sebagian dari Elasticsearch
  • Query logic sangat kompleks dan dipakai dari banyak tempat yang berbeda

Jangan gunakan ketika:

  • Project kamu sepenuhnya berbasis Eloquent dan tidak ada rencana konkret untuk pindah ORM
  • Repository methods hanya mendelegasikan langsung ke Eloquent tanpa logika tambahan
  • Project berukuran kecil sampai menengah dengan tim kecil — overhead-nya tidak sepadan

Observer Pattern

Observer digunakan untuk merespons event yang terjadi pada Eloquent model, seperti creating, created, updating, deleted. Laravel menyediakan dukungan native via php artisan make:observer.

// UserObserver.php
class UserObserver
{
    public function created(User $user): void
    {
        // Kirim email selamat datang setelah user dibuat
        Mail::to($user)->send(new WelcomeEmail($user));
    }

    public function deleting(User $user): void
    {
        // Hapus data terkait sebelum user dihapus
        $user->profile()->delete();
        $user->tokens()->delete();
    }
}
// Daftarkan di AppServiceProvider atau EventServiceProvider
User::observe(UserObserver::class);

Gunakan ketika:

  • Ada side effect yang konsisten harus terjadi setiap kali model mengalami event tertentu
  • Kamu ingin menjaga model tetap bersih dari logika yang tidak relevan dengan datanya sendiri

Jangan gunakan ketika:

  • Side effect hanya terjadi dalam kondisi tertentu — gunakan event/listener eksplisit yang lebih mudah di-trace
  • Logic di observer bergantung pada state request atau user session — observer seharusnya tidak tahu konteks HTTP
  • Debugging menjadi sulit karena behavior yang tersembunyi di observer tidak terlihat dari flow utama

Event dan Listener

Laravel menyediakan event system yang decoupled. Cocok untuk memisahkan aksi utama dari konsekuensi-konsekuensinya.

// Dispatch event setelah order selesai
class OrderService
{
    public function complete(Order $order): void
    {
        $order->update(['status' => 'completed']);

        event(new OrderCompleted($order));
        // Dari sini, listener yang handle email, notifikasi, laporan, dsb.
    }
}
// OrderCompleted Event
class OrderCompleted
{
    public function __construct(public readonly Order $order) {}
}
// Listener: kirim invoice
class SendInvoiceEmail implements ShouldQueue
{
    public function handle(OrderCompleted $event): void
    {
        Mail::to($event->order->user)->send(new Invoice($event->order));
    }
}

Gunakan ketika:

  • Satu aksi memiliki banyak konsekuensi yang tidak saling bergantung (kirim email, update laporan, notifikasi Slack, dsb.)
  • Konsekuensi tersebut bisa dijalankan secara async via queue
  • Kamu ingin menambah atau menghapus konsekuensi tanpa mengubah kode aksi utama

Jangan gunakan ketika:

  • Konsekuensi hanya satu dan sederhana — memanggil langsung jauh lebih mudah dibaca
  • Logic di listener harus selesai sebelum response dikembalikan dan tidak bisa di-queue — pertimbangkan memanggil langsung agar flow lebih eksplisit

Strategy Pattern

Strategy digunakan ketika sebuah aksi bisa dilakukan dengan beberapa cara berbeda, dan cara mana yang dipakai ditentukan saat runtime.

// Interface strategy
interface PaymentGateway
{
    public function charge(int $amount, array $data): PaymentResult;
}

class StripeGateway implements PaymentGateway
{
    public function charge(int $amount, array $data): PaymentResult
    {
        // Implementasi Stripe
    }
}

class MidtransGateway implements PaymentGateway
{
    public function charge(int $amount, array $data): PaymentResult
    {
        // Implementasi Midtrans
    }
}
// PaymentService memilih strategy berdasarkan config atau input
class PaymentService
{
    public function __construct(protected PaymentGateway $gateway) {}

    public function pay(int $amount, array $data): PaymentResult
    {
        return $this->gateway->charge($amount, $data);
    }
}
// Binding berdasarkan kondisi
$this->app->bind(PaymentGateway::class, function () {
    return match(config('payment.default')) {
        'stripe'    => new StripeGateway(),
        'midtrans'  => new MidtransGateway(),
        default     => throw new \InvalidArgumentException('Unknown gateway'),
    };
});

Gunakan ketika:

  • Ada beberapa algoritma atau implementasi yang bisa saling menggantikan
  • Pilihan implementasi ditentukan oleh konfigurasi, user input, atau kondisi runtime
  • Kamu ingin menambah implementasi baru tanpa mengubah kode yang sudah ada

Jangan gunakan ketika:

  • Hanya ada satu implementasi dan tidak ada rencana konkret untuk menambah yang lain
  • Kondisi pemilihan sangat sederhana — if/else biasa seringkali lebih jelas

Action Pattern

Action adalah pattern yang dipopulerkan oleh komunitas Laravel belakangan ini. Setiap “aksi” bisnis diwakili oleh satu class dengan satu method execute atau handle. Ini adalah versi lebih granular dari Service.

class CreateUserAction
{
    public function execute(array $data): User
    {
        $user = User::create([
            'name'     => $data['name'],
            'email'    => $data['email'],
            'password' => Hash::make($data['password']),
        ]);

        $user->assignRole('member');

        event(new UserRegistered($user));

        return $user;
    }
}
// Di controller
class AuthController extends Controller
{
    public function register(RegisterRequest $request, CreateUserAction $action): JsonResponse
    {
        $user = $action->execute($request->validated());
        return response()->json($user, 201);
    }
}

Gunakan ketika:

  • Kamu ingin granularitas lebih dari Service — setiap use case punya class sendiri
  • Action perlu dipakai dari banyak titik: controller, job, command, test
  • Project besar dengan banyak developer — satu class per aksi menghindari service class yang semakin gemuk

Jangan gunakan ketika:

  • Project kecil di mana satu Service class sudah cukup mengelola beberapa operasi terkait
  • Jumlah class bertambah terlalu cepat untuk keuntungan yang tidak proporsional

Ringkasan Keputusan

Pattern Cocok untuk Hindari jika
Service Business logic multi-step, reuse antar entry point Logic hanya satu baris, tidak ada reuse
Repository Doctrine ORM, multi data source, query sangat kompleks Semua pakai Eloquent, tidak ada rencana ganti ORM
Observer Side effect konsisten di setiap lifecycle event model Kondisional, bergantung request context, sulit di-debug
Event Banyak konsekuensi async, decoupled dari aksi utama Konsekuensi tunggal, harus sinkron dan simpel
Strategy Banyak implementasi yang bisa saling menggantikan Hanya satu implementasi sekarang dan seterusnya
Action Project besar, use case spesifik, satu class satu tujuan Project kecil, overhead class terlalu tinggi

Catatan Akhir

Pattern bukan tujuan, melainkan alat. Menulis User::create($data) langsung di controller bukan berarti kode kamu buruk — itu bisa berarti kamu tidak menambah complexity yang tidak diperlukan. Keputusan untuk menggunakan pattern seharusnya didorong oleh masalah konkret yang kamu hadapi, bukan oleh keinginan agar kode terlihat “enterprise”.

Satu pertanyaan yang berguna sebelum menambah layer baru: “Masalah apa yang pattern ini selesaikan di project ini hari ini?” Jika jawabannya tidak jelas, kemungkinan besar kamu tidak membutuhkannya.