Thin Controller — Service Layer & Action Class di Laravel

Kalau kamu sudah cukup lama nulis kode Laravel, pasti pernah sampai di titik di mana kamu buka file controller dan merasa — “ini kok jadi novel?”

Ratusan baris. Validasi di sini. Query Eloquent di sana. Kirim email di bawah. Hitung diskon di tengah. Semua dalam satu method store().

Itulah yang disebut Fat Controller — dan ini salah satu “dosa” paling umum di aplikasi Laravel yang sudah berkembang.

Di artikel ini, kita bahas cara membereskannya dengan dua pendekatan yang paling umum dipakai: Service Layer dan Action Class.


🤔 Masalah dengan Fat Controller

Sebelum masuk ke solusinya, penting untuk ngerti dulu kenapa Fat Controller itu menjadi masalah — bukan hanya soal estetika kode.

Bayangin controller ini:

class OrderController extends Controller
{
    public function store(Request $request)
    {
        // Validasi
        $request->validate([
            'product_id' => 'required|exists:products,id',
            'quantity'   => 'required|integer|min:1',
        ]);

        // Cek stok
        $product = Product::findOrFail($request->product_id);
        if ($product->stock < $request->quantity) {
            return response()->json(['message' => 'Stok tidak cukup'], 422);
        }

        // Hitung harga
        $subtotal = $product->price * $request->quantity;
        $discount = $subtotal > 500000 ? $subtotal * 0.1 : 0;
        $total = $subtotal - $discount;

        // Buat order
        $order = Order::create([
            'user_id'    => auth()->id(),
            'product_id' => $product->id,
            'quantity'   => $request->quantity,
            'total'      => $total,
        ]);

        // Kurangi stok
        $product->decrement('stock', $request->quantity);

        // Kirim email konfirmasi
        Mail::to(auth()->user())->send(new OrderConfirmation($order));

        return response()->json($order, 201);
    }
}

Kodenya bekerja. Tapi ada masalah yang tidak terlihat langsung:

  • Kalau logika hitung harga berubah, kamu harus buka file controller ini.
  • Kalau mau pakai logika yang sama dari tempat lain (command artisan, job background), kamu harus copy-paste.
  • Kalau mau unit test hanya bagian perhitungan harga — tidak bisa, karena dia terkubur di dalam controller yang butuh HTTP request.

Prinsip Single Responsibility bilang: satu kelas, satu alasan untuk berubah. Controller di atas punya setidaknya lima alasan berbeda untuk berubah.


🏗️ Service Layer — “Kelas Pembantu yang Spesialis”

Analoginya

Bayangin controller seperti resepsionis hotel.

Tugasnya adalah: terima tamu, arahkan ke tempat yang tepat, berikan respon. Bukan tugasnya untuk memasak makanan, membersihkan kamar, atau mengurus pembukuan.

Untuk hal-hal itu, resepsionis memanggil spesialis — dapur, housekeeping, akunting. Masing-masing ahli di bidangnya sendiri.

Service Layer adalah spesialis itu. Controller cukup memanggil mereka, tidak perlu tahu cara kerjanya.

Strukturnya

app/
├── Http/
│   └── Controllers/
│       └── OrderController.php   ← Resepsionis: terima request, panggil service, return response
│
├── Services/
│   └── OrderService.php          ← Spesialis: semua logika bisnis order

Kodenya

📁 app/Services/OrderService.php

Semua logika bisnis dipindahkan ke sini. Controller tidak perlu tahu cara kerjanya — cukup panggil method-nya.

<?php

namespace App\Services;

use App\Models\Order;
use App\Models\Product;
use App\Mail\OrderConfirmation;
use Illuminate\Support\Facades\Mail;

class OrderService
{
    public function createOrder(array $data, int $userId): Order
    {
        $product = Product::findOrFail($data['product_id']);

        // Cek stok
        if ($product->stock < $data['quantity']) {
            throw new \Exception('Stok tidak cukup');
        }

        // Hitung harga — logika ini sekarang mudah dites secara terisolasi
        $total = $this->calculateTotal($product->price, $data['quantity']);

        // Buat order
        $order = Order::create([
            'user_id'    => $userId,
            'product_id' => $product->id,
            'quantity'   => $data['quantity'],
            'total'      => $total,
        ]);

        // Kurangi stok
        $product->decrement('stock', $data['quantity']);

        // Kirim notifikasi
        Mail::to(auth()->user())->send(new OrderConfirmation($order));

        return $order;
    }

    private function calculateTotal(int $price, int $quantity): int
    {
        $subtotal = $price * $quantity;
        $discount = $subtotal > 500000 ? $subtotal * 0.1 : 0;

        return $subtotal - $discount;
    }
}

📁 app/Http/Controllers/OrderController.php

Controller sekarang cukup tiga hal: terima request → panggil service → return response.

<?php

namespace App\Http\Controllers;

use App\Services\OrderService;
use Illuminate\Http\Request;

class OrderController extends Controller
{
    public function __construct(
        private OrderService $orderService
    ) {}

    public function store(Request $request)
    {
        $request->validate([
            'product_id' => 'required|exists:products,id',
            'quantity'   => 'required|integer|min:1',
        ]);

        $order = $this->orderService->createOrder(
            $request->only('product_id', 'quantity'),
            auth()->id()
        );

        return response()->json($order, 201);
    }
}

📌 Bandingkan controller ini dengan versi sebelumnya. Yang tersisa hanya koordinasi — validasi input, panggil yang bertanggung jawab, kembalikan hasilnya. Tidak lebih.


⚡ Action Class — “Satu Kelas, Satu Tugas”

Analoginya

Service Layer seperti toko servis elektronik yang bisa memperbaiki TV, AC, dan kulkas. Satu toko, banyak keahlian.

Action Class seperti tukang spesialis yang hanya bisa satu hal tapi melakukannya dengan sempurna.

Ada CreateOrderAction — tugasnya satu: membuat order. Ada CancelOrderAction — tugasnya satu: membatalkan order. Ada RefundOrderAction — tugasnya satu: memproses refund.

Masing-masing kelas kecil, fokus, dan sangat mudah dibaca.

Strukturnya

app/
├── Http/
│   └── Controllers/
│       └── OrderController.php
│
├── Actions/
│   ├── CreateOrderAction.php     ← hanya tahu cara membuat order
│   ├── CancelOrderAction.php     ← hanya tahu cara membatalkan order
│   └── RefundOrderAction.php     ← hanya tahu cara memproses refund

Kodenya

📁 app/Actions/CreateOrderAction.php

Konvensi umum: satu Action punya satu method publik, sering dinamai execute() atau handle().

<?php

namespace App\Actions;

use App\Models\Order;
use App\Models\Product;
use App\Mail\OrderConfirmation;
use Illuminate\Support\Facades\Mail;

class CreateOrderAction
{
    // Satu method publik — itulah satu-satunya yang dilakukan kelas ini
    public function execute(array $data, int $userId): Order
    {
        $product = Product::findOrFail($data['product_id']);

        if ($product->stock < $data['quantity']) {
            throw new \Exception('Stok tidak cukup');
        }

        $subtotal = $product->price * $data['quantity'];
        $discount = $subtotal > 500000 ? $subtotal * 0.1 : 0;

        $order = Order::create([
            'user_id'    => $userId,
            'product_id' => $product->id,
            'quantity'   => $data['quantity'],
            'total'      => $subtotal - $discount,
        ]);

        $product->decrement('stock', $data['quantity']);
        Mail::to(auth()->user())->send(new OrderConfirmation($order));

        return $order;
    }
}

📁 app/Http/Controllers/OrderController.php

<?php

namespace App\Http\Controllers;

use App\Actions\CreateOrderAction;
use Illuminate\Http\Request;

class OrderController extends Controller
{
    public function store(Request $request, CreateOrderAction $action)
    {
        $request->validate([
            'product_id' => 'required|exists:products,id',
            'quantity'   => 'required|integer|min:1',
        ]);

        $order = $action->execute(
            $request->only('product_id', 'quantity'),
            auth()->id()
        );

        return response()->json($order, 201);
    }
}

📌 Perhatikan CreateOrderAction di-inject langsung ke method — bukan di constructor. Ini method injection, dan cocok untuk action karena setiap endpoint controller biasanya butuh action yang berbeda.


🗺️ Gambaran Besar — Posisi Masing-Masing

Request masuk
      ↓
┌─────────────────────────────────────────────┐
│  Controller  (tipis — hanya koordinasi)     │
│  • Terima input                             │
│  • Validasi format                          │
│  • Panggil Service / Action                 │
│  • Return response                          │
└──────────────┬──────────────────────────────┘
               │
    ┌──────────┴──────────┐
    ▼                     ▼
Service Layer         Action Class
(banyak method,       (satu method,
 satu domain)          satu tugas)
    │                     │
    └──────────┬──────────┘
               ▼
        Model / Database

🆚 Service Layer vs Action Class — Mana yang Lebih Baik?

Tidak ada yang lebih baik secara mutlak. Keduanya punya tempat masing-masing.

Service Layer lebih cocok ketika: Beberapa operasi dalam satu domain saling berbagi logika atau state. Misalnya OrderService punya createOrder(), calculateTotal(), dan checkStock() — ketiganya erat kaitannya dan sering dipakai bersama.

// Satu service, banyak method yang saling berkaitan
class OrderService
{
    public function createOrder(...) { ... }
    public function cancelOrder(...) { ... }
    public function getOrderHistory(...) { ... }
}

Action Class lebih cocok ketika: Setiap operasi berdiri sendiri dan tidak butuh berbagi state dengan operasi lain. Lebih mudah dibaca karena nama file-nya langsung menjelaskan apa yang dilakukan.

// Satu file, satu tugas, nama yang berbicara sendiri
CreateOrderAction.php
CancelOrderAction.php
ProcessRefundAction.php

🧭 Jadi, Kapan Pakai Yang Mana?

Pakai Service Layer ketika: Kamu punya satu domain (misalnya Order, Payment, User) dengan banyak operasi yang saling berkaitan, atau ada logika yang perlu dibagi antar beberapa method.

Pakai Action Class ketika: Operasi-operasi dalam satu domain cenderung berdiri sendiri, dan kamu ingin setiap file punya satu tanggung jawab yang sangat jelas. Lebih mudah dinavigasi di proyek besar.

Pakai keduanya ketika: Action Class menangani satu operasi spesifik, tapi di dalamnya boleh memanggil Service jika butuh logika bersama. Tidak ada aturan yang bilang keduanya tidak boleh koeksistensi.


📊 Perbandingan Singkat

Fat ControllerService LayerAction Class
Ukuran fileBesarSedangKecil
Tanggung jawabBanyakPer domainPer operasi
Reusability
Mudah dites
Navigasi di proyek besarSusahLumayanMudah
Cocok untukPrototipe cepatLogika domain kompleksOperasi mandiri

💡 Ingat!

Thin Controller bukan berarti controller yang bodoh — dia tetap bertanggung jawab atas dua hal: memahami HTTP (request, response, status code) dan mendelegasikan ke lapisan yang tepat.

Yang dipindahkan ke Service atau Action adalah logika bisnis — aturan-aturan yang menentukan bagaimana aplikasi bekerja. Logika itu tidak perlu tahu apakah dia dipanggil dari HTTP request, Artisan command, atau job di background.

Dan itu justru kekuatan terbesarnya: sekali logika bisnis terpisah dari controller, kamu bisa memanggilnya dari mana saja — tanpa ubah satu baris pun di dalamnya.