Chapter sebelumnya menutup dengan janji: pola yang lebih kecil dan lincah bernama action class. Service class seperti RegisterUser dari chapter Service Classes menampung satu use case dalam satu method, tetapi seiring waktu method-nya berpeluang bertambah — register(), updateProfil(), resetPassword() — dan kelas kembali membesar. Action class memotong lebih tegas lagi: satu kelas, satu aksi bisnis, satu method. Kali ini kita membangun PlaceOrder yang nyata untuk kedai kopi: dua tabel (orders berisi user_id, total_cents, status; order_items berisi order_id, menu, harga_cents, jumlah), satu action, dan lima test yang membuktikannya bisa dipanggil dari mana saja.

Satu Kelas, Satu Kata Kerja #

Nama action class adalah kata kerja imperatif: RegisterUser, PublishPost, PlaceOrder. Nama semacam ini membuat daftar berkas di app/Actions/ terbaca seperti daftar hal yang bisa dilakukan aplikasi. Sebaliknya, nama benda seperti OrderManager menyembunyikan aksi — tidak ada yang tahu Manager ini mengelola apa saja — dan biasanya berakhir menampung semua method yang tidak jelas tempatnya. Jika sebuah aksi tidak bisa dinamai dengan satu kata kerja, kemungkinan besar isinya adalah dua aksi berbeda.

Berikut action PlaceOrder lengkap dengan satu-satunya method publiknya:

php
<?php

namespace App\Actions;

use App\Exceptions\KeranjangKosong;
use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Facades\DB;

class PlaceOrder
{
    /**
     * @param  array<int, array{menu: string, harga_cents: int, jumlah: int}>  $items
     */
    public function __invoke(User $buyer, array $items): Order
    {
        if ($items === []) {
            throw new KeranjangKosong;
        }

        $total = collect($items)->sum(
            fn (array $item) => $item['harga_cents'] * $item['jumlah'],
        );

        return DB::transaction(function () use ($buyer, $items, $total) {
            $order = $buyer->orders()->create([
                'total_cents' => $total,
                'status' => 'baru',
            ]);

            $order->items()->createMany($items);

            return $order;
        });
    }
}

Mari kita baca aliran datanya dari atas ke bawah. Parameter masuk lewat signature — pembeli dan daftar item, dengan bentuk array yang didokumentasikan lewat PHPDoc agar IDE bisa memberi peringatan ketika bentuknya salah. Aturan bisnis dilanggar berarti exception: keranjang kosong bukan urusan validasi HTTP, melainkan kondisi domain yang juga bisa terjadi dari command atau job, jadi action melempar KeranjangKosong — pengecualian khusus yang kita definisikan sendiri di app/Exceptions/. Hasil keluar sebagai nilai kembali: pemanggil menerima model Order utuh, bukan array mentah. Terakhir, action memegang unit of work-nya sendiri — transaksi database terjadi di dalam action, sehingga pemanggil tidak perlu tahu bahwa dua tabel ikut tersentuh.

Dipanggil Seperti Fungsi #

Method ajaib PHP __invoke() membuat objek bisa dipanggil persis seperti fungsi. Berbekal dependency injection yang sudah kita kenal di chapter Dependency Injection , controller cukup menerima action lalu memanggilnya:

php
<?php

// app/Http/Controllers/OrderController.php — method utuh
public function simpan(Request $request, PlaceOrder $placeOrder): JsonResponse
{
    $data = $request->validate([
        'email' => ['required', 'email'],
        'items' => ['required', 'array', 'min:1'],
        'items.*.menu' => ['required', 'string', 'max:100'],
        'items.*.harga_cents' => ['required', 'integer', 'min:0'],
        'items.*.jumlah' => ['required', 'integer', 'between:1,99'],
    ]);

    $buyer = User::where('email', $data['email'])->firstOrFail();

    $order = $placeOrder($buyer, $data['items']);

    return response()->json([
        'id' => $order->id,
        'total_cents' => $order->total_cents,
    ], 201);
}

Perhatikan baris $placeOrder($buyer, $data['items']) — tanda kurung langsung menempel pada variabel objek tanpa nama method. Sebagian tim memilih konvensi method bernama execute() atau handle() dengan alasan lebih mudah dicari di editor; keduanya sama sahnya. Yang penting satu kode basis memilih satu gaya dan mempertahankannya.

Action atau Service #

Action dan service bukan pesaing, melainkan dua ukuran untuk kebutuhan yang sama — memindahkan logika bisnis keluar dari controller dan model:

AspekAction classService class
CakupanSatu use caseSekelompok use case satu domain
Method publikSatu (__invoke())Beberapa, saling berkerabat
Ukuran tipikal20–60 barisBisa ratusan baris
Berbagi helperTidak perluAntar method di kelas yang sama
Cocok ketikaAksi berdiri sendiri, jarang berbagi kodeBeberapa operasi berbagi state atau helper yang sama

Praktik yang umum: mulai dengan action untuk setiap aksi baru, dan gabungkan beberapa action menjadi service ketika ketiganya mulai berbagi method privat yang sama. Arah sebaliknya juga sehat — memecah service gemuk menjadi banyak action kecil.

Satu Action, Tiga Pintu Masuk #

Sama seperti service, action bisa dipanggil dari job dan command tanpa satu baris pun yang diduplikasi. Job menerima action lewat method handle():

php
<?php

// app/Jobs/BuatPesananJob.php — bagian inti
public function handle(PlaceOrder $placeOrder): void
{
    $placeOrder($this->buyer, $this->items);
}

Command artisan juga demikian:

php
<?php

// app/Console/Commands/BuatPesananCommand.php — bagian inti
public function handle(PlaceOrder $placeOrder): int
{
    $buyer = User::where('email', $this->argument('email'))->firstOrFail();

    $order = $placeOrder($buyer, [[
        'menu' => (string) $this->option('menu'),
        'harga_cents' => (int) $this->option('harga'),
        'jumlah' => (int) $this->option('jumlah'),
    ]]);

    $this->info("Pesanan #{$order->id} tersimpan (total: {$order->total_cents})");

    return self::SUCCESS;
}

Satu berkas test menguji kelima pemanggilan sekaligus — dua test langsung dan tiga pintu masuk:

Output yang Dihasilkan:

text
  PASS  Tests\Feature\PlaceOrderActionTest
  ✓  action bisa dites langsung tanpa http                    0.15s
  ✓  action menolak keranjang kosong                          0.01s
  ✓  controller memanggil action                               0.03s
  ✓  job memanggil action                                      0.01s
  ✓  command memanggil action                                  0.01s

  Tests:    5 passed (11 assertions)
  Duration: 0.32s

Menguji Action Secara Langsung #

Dua test pertama pada output di atas layak diperhatikan karena keduanya tidak menyentuh HTTP sama sekali:

php
<?php

// tests/Feature/PlaceOrderActionTest.php — dua test langsung
public function test_action_bisa_dites_langsung_tanpa_http(): void
{
    $budi = User::factory()->create(['name' => 'Budi']);

    $placeOrder = $this->app->make(PlaceOrder::class);

    $order = $placeOrder($budi, [
        ['menu' => 'Kopi Susu Gula Aren', 'harga_cents' => 18000, 'jumlah' => 2],
        ['menu' => 'Roti Bakar', 'harga_cents' => 15000, 'jumlah' => 1],
    ]);

    $this->assertSame(51000, $order->total_cents);
    $this->assertSame('baru', $order->status);
    $this->assertDatabaseCount('order_items', 2);
}

public function test_action_menolak_keranjang_kosong(): void
{
    $budi = User::factory()->create();

    $placeOrder = $this->app->make(PlaceOrder::class);

    $this->expectException(KeranjangKosong::class);
    $this->expectExceptionMessage('keranjang kosong');

    $placeOrder($budi, []);
}

Test pertama memanggil action seperti fungsi biasa — $placeOrder($budi, [...]) — setelah container menyediakannya lewat $this->app->make(). Test kedua membuktikan jalur kegagalan: exception domain tetap bisa diuji tanpa melewati route maupun middleware. Ketika assertion total salah, kita tahu persis bahwa logika perhitungan action-nya yang rusak — bukan routing, session, atau serialisasi response. Imbalan sekundernya, test seperti ini berjalan dalam milidetik karena tidak ada satu pun lapisan HTTP yang ikut di-boot.

Sejauh ini semua action kita tinggal rapi di app/Actions/ dan service di app/Services/ — struktur folder berdasarkan jenis kelas. Ketika aplikasi menumbuhkan puluhan model dan action, pengelompokan berdasarkan jenis mulai terasa sempit. Bab berikutnya membalik sudut pandangnya: mengelompokkan kode berdasarkan domain fitur.