Controller yang awalnya cuma 10 baris perlahan membengkak jadi 200 baris. Validasi, query Eloquent, kirim notifikasi, update saldo, log aktivitas — semuanya tumpuk di satu method store(). Ketika ada bug, tidak ada yang tahu persis di mana harus mulai mencari, dan test feature-nya berubah jadi mimpi buruk karena satu endpoint menyentuh lima tabel sekaligus.
Service Action Pattern adalah cara sederhana untuk memotong spiral itu. Alih-alih mengumpulkan semua logika di controller atau menumpuknya di Service class yang gemuk, setiap aksi bisnis dipecah menjadi kelas kecil yang fokus pada satu pekerjaan. Hasilnya: controller kembali tipis, logic bisnis bisa dipakai ulang dari mana saja (HTTP, queue, command), dan unit test jadi cepat dan terisolasi.
Artikel ini membahas alasan memilih pattern ini dibanding Service Class tradisional, struktur folder yang direkomendasikan, dan contoh implementasi lengkap di Laravel 13 untuk sebuah skenario manajemen tugas.
Kenapa Service Action, Bukan Service Class Biasa
Banyak tutorial Laravel memperkenalkan Service Layer sebagai solusi pertama untuk membersihkan controller. Idenya benar, tapi di praktik jangka panjang, Service Class sering berakhir jadi kumpulan method yang tidak berhubungan — TaskService bisa punya create(), update(), archive(), duplicate(), assignMember(), dan seterusnya. Akhirnya kelas ini punya terlalu banyak dependency di constructor, melanggar Single Responsibility Principle, dan susah di-test karena setiap test perlu mem-bypass method yang tidak relevan.
Service Action Pattern membalik perspektifnya: satu aksi, satu kelas. Setiap use case bisnis — misalnya "buat tugas baru", "pindahkan tugas ke kolom lain", "selesaikan tugas" — hidup di kelas tersendiri dengan satu method publik, biasanya handle() atau execute(). Kelas ini hanya meminta dependency yang benar-benar dibutuhkan aksi tersebut, tidak lebih.
Perbandingan singkatnya bisa dilihat di tabel berikut:
| Aspek | Service Class | Service Action |
|---|---|---|
| Cakupan | Banyak method dalam satu kelas | Satu aksi per kelas |
| Dependency | Menumpuk di constructor | Minimal, sesuai kebutuhan aksi |
| Testability | Sering butuh mock banyak hal | Isolasi natural |
| Reuse | Via method call | Via invokasi kelas |
Service Action cocok sekali dipasangkan dengan queue. Karena kelas aksi sudah berisi semua logic, memindahkannya ke background job tinggal membungkusnya dengan dispatch() tanpa refactor besar.
Struktur Folder yang Direkomendasikan
Tidak ada aturan baku dari Laravel soal ini, tapi konvensi yang umum dipakai komunitas adalah mengelompokkan action per domain di bawah app/Actions. Untuk aplikasi manajemen tugas, strukturnya bisa seperti ini:
app/
├── Actions/
│ └── Task/
│ ├── CreateTaskAction.php
│ ├── MoveTaskToColumnAction.php
│ ├── CompleteTaskAction.php
│ └── ArchiveTaskAction.php
├── Http/
│ └── Controllers/
│ └── TaskController.php
├── Models/
│ └── Task.php
└── Services/
└── TaskNotifier.php
Perhatikan bahwa Services/ tetap ada, tapi perannya berbeda. Service sekarang dipakai untuk infrastruktur yang reusable — pengiriman notifikasi, integrasi API eksternal, generator PDF — bukan untuk orkestrasi bisnis. Action yang mengorkestrasi, Service yang menyediakan alat.
Membuat Action Pertama untuk Aplikasi Manajemen Tugas
Skenarionya: aplikasi manajemen tugas dengan papan bergaya Kanban. Ketika user membuat tugas baru, sistem harus menyimpan tugas, mengurutkannya di posisi terakhir kolom, mencatat aktivitas, dan mengirim notifikasi ke anggota tim yang di-assign.
Mulai dengan membuat Action class-nya. Laravel tidak punya make:action bawaan, jadi gunakan generator kelas generik:
php artisan make:class Actions/Task/CreateTaskAction
Isi action-nya dengan satu method publik handle() yang menerima data mentah dan mengembalikan model Task yang sudah siap:
// app/Actions/Task/CreateTaskAction.php
<?php
namespace App\Actions\Task;
use App\Models\Task;
use App\Models\BoardColumn;
use App\Services\TaskNotifier;
use Illuminate\Support\Facades\DB;
class CreateTaskAction
{
public function __construct(
private TaskNotifier $notifier,
) {}
/**
* @param array{title: string, description: ?string, column_id: int, assignee_ids: array<int>} $payload
*/
public function handle(array $payload): Task
{
return DB::transaction(function () use ($payload) {
$column = BoardColumn::findOrFail($payload['column_id']);
$task = $column->tasks()->create([
'title' => $payload['title'],
'description' => $payload['description'] ?? null,
'position' => $column->tasks()->max('position') + 1,
]);
$task->assignees()->sync($payload['assignee_ids']);
activity()
->performedOn($task)
->log('task.created');
$this->notifier->notifyAssignees($task);
return $task;
});
}
}
Ada beberapa hal yang perlu disorot dari kode di atas. Pertama, DB::transaction() memastikan seluruh rangkaian operasi bersifat atomik — kalau satu langkah gagal, tidak ada task setengah jadi yang tersisa di database. Kedua, TaskNotifier di-inject lewat constructor property promotion, jadi unit test nanti bisa menggantinya dengan mock tanpa menyentuh kelas lain. Ketiga, satu-satunya yang diketahui action ini adalah cara membuat tugas — ia tidak tahu menahu soal HTTP request, response JSON, atau format form.
Memanggil Action dari Controller
Controller sekarang tinggal jadi penerjemah antara HTTP dan domain. Tugasnya hanya tiga: validasi input, panggil action, kembalikan response.
// app/Http/Controllers/TaskController.php
<?php
namespace App\Http\Controllers;
use App\Actions\Task\CreateTaskAction;
use App\Http\Requests\StoreTaskRequest;
use App\Http\Resources\TaskResource;
class TaskController extends Controller
{
public function store(StoreTaskRequest $request, CreateTaskAction $action)
{
$task = $action->handle($request->validated());
return TaskResource::make($task)
->response()
->setStatusCode(201);
}
}
Laravel secara otomatis akan me-resolve CreateTaskAction lewat service container, termasuk semua dependency-nya. Tidak perlu binding manual di AppServiceProvider. Validasi tetap tinggal di FormRequest sehingga aturannya bisa dipakai ulang oleh controller lain atau test feature.
Perbandingan dengan versi lama controller yang gemuk tidak perlu dijelaskan panjang lebar — yang jelas, method store() yang tadinya 80 baris sekarang cuma 5 baris dan langsung bisa dibaca dalam sekali lihat.
Aksi Kedua: Memindahkan Tugas Antar Kolom
Untuk menunjukkan bahwa pattern ini tidak cuma cocok untuk create, berikut aksi kedua yang lebih kompleks: memindahkan tugas dari satu kolom ke kolom lain sambil menjaga urutan posisi tetap rapi.
// app/Actions/Task/MoveTaskToColumnAction.php
<?php
namespace App\Actions\Task;
use App\Models\Task;
use App\Models\BoardColumn;
use Illuminate\Support\Facades\DB;
class MoveTaskToColumnAction
{
public function handle(Task $task, BoardColumn $targetColumn, int $targetPosition): Task
{
return DB::transaction(function () use ($task, $targetColumn, $targetPosition) {
$sourceColumn = $task->column;
$sourceColumn->tasks()
->where('position', '>', $task->position)
->decrement('position');
$targetColumn->tasks()
->where('position', '>=', $targetPosition)
->increment('position');
$task->update([
'board_column_id' => $targetColumn->id,
'position' => $targetPosition,
]);
return $task->fresh();
});
}
}
Logic pengurutan posisi seperti ini yang paling sering jadi sumber bug kalau dipaksa masuk ke controller. Dengan meletakkannya di action tersendiri, aturannya punya satu tempat tinggal yang jelas dan bisa di-test secara terpisah dari lapisan HTTP.
Testing Action Secara Terisolasi
Karena action adalah kelas PHP biasa tanpa ketergantungan pada request HTTP, unit test-nya bisa dijalankan sangat cepat. Gunakan Pest untuk menjaga sintaks tetap ringkas:
// tests/Feature/Actions/CreateTaskActionTest.php
<?php
use App\Actions\Task\CreateTaskAction;
use App\Models\BoardColumn;
use App\Models\User;
use App\Services\TaskNotifier;
it('menyimpan tugas baru di posisi terakhir kolom', function () {
$column = BoardColumn::factory()->create();
BoardColumn::factory()->for($column->board)->create();
$column->tasks()->create(['title' => 'Tugas lama', 'position' => 1]);
$assignee = User::factory()->create();
$this->mock(TaskNotifier::class)
->shouldReceive('notifyAssignees')
->once();
$action = app(CreateTaskAction::class);
$task = $action->handle([
'title' => 'Rilis versi beta',
'description' => 'Cek checklist QA sebelum tag.',
'column_id' => $column->id,
'assignee_ids' => [$assignee->id],
]);
expect($task->position)->toBe(2)
->and($task->assignees)->toHaveCount(1);
});
Test ini tidak menyentuh route, tidak butuh actingAs(), dan tidak bergantung pada middleware. Yang diuji adalah perilaku bisnis murni — dan itulah titik kritisnya. Ketika ada regresi, test akan jatuh tepat di lapisan yang salah, bukan memaksa pengembang menelusuri stack trace dari HTTP kernel.
Jangan tergoda memanggil CreateTaskAction dari MoveTaskToColumnAction atau sebaliknya hanya karena "sekalian". Action seharusnya tidak saling bergantung langsung — kalau dua aksi sering dipakai bersama, buat action baru yang meng-orkestrasi keduanya.
Catatan Penting Sebelum Mengadopsi
Beberapa hal yang sering jadi jebakan ketika mulai menerapkan pattern ini di proyek nyata:
- Jangan paksakan untuk aksi trivial. Endpoint yang cuma melakukan
Model::find()tidak butuh action. Pattern ini berguna ketika ada orkestrasi beberapa langkah atau logic yang rentan regresi. - Tetap jaga konsistensi penamaan method. Pilih salah satu —
handle(),execute(), atau__invoke()— lalu pakai di semua action. Campur aduk hanya akan membingungkan developer baru di tim. - Action bukan tempat query kompleks. Query yang dipakai ulang di banyak action sebaiknya tinggal di Query Class atau scope Eloquent, bukan di-copy paste.
- Pertimbangkan DTO untuk payload. Ketika jumlah parameter action tumbuh lebih dari empat, ganti array asosiatif dengan Data Transfer Object agar kontraknya eksplisit. Topik ini sudah dibahas di perbedaan DTO dan Resource di Laravel.
Untuk diskusi lebih luas tentang pattern arsitektural lain seperti Repository dan CQRS, panduan enterprise architecture pattern Laravel bisa jadi bacaan lanjutan yang relevan.
Kesimpulan
Service Action Pattern bukan barang ajaib, tapi ia memberi satu jawaban yang konsisten untuk pertanyaan yang selalu muncul di proyek Laravel yang bertumbuh: "logic ini taruh di mana?". Dengan menempatkan setiap aksi bisnis di kelasnya sendiri, kode jadi lebih mudah dibaca, dipakai ulang di konteks HTTP maupun queue, dan — yang paling berharga dalam jangka panjang — tahan terhadap perubahan karena setiap perubahan punya area dampak yang jelas.







