Sejak chapter pembuka Section ini kita menyinggung nama “service” berkali-kali — PostPublisher di refaktor, PusherNotifikasi di container, RegisterUser di pembahasan DI. Saatnya membahas kelompok kelas tersebut secara utuh. Service class adalah wadah untuk logika bisnis — pekerjaan yang bukan milik controller (urusan HTTP), bukan pula milik model (urusan data dan aturannya). Chapter ini membedah anatomi service yang sehat, membandingkannya dengan model dan controller, lalu membuktikan bahwa satu service bisa dilayani dari tiga pintu masuk berbeda dengan kode yang persis sama.
Anatomi Sebuah Application Service #
Berikut service pendaftaran pengguna lengkap dengan semua bagian yang membentuk anatominya:
<?php
namespace App\Services;
use App\Contracts\PusherNotifikasi;
use App\Exceptions\EmailSudahDipakai;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
class RegisterUser
{
public function __construct(
protected PusherNotifikasi $notifikasi,
) {}
public function register(string $name, string $email, string $password): User
{
if (User::where('email', $email)->exists()) {
throw new EmailSudahDipakai($email);
}
$user = User::create([
'name' => $name,
'email' => $email,
'password' => Hash::make($password),
]);
$this->notifikasi->kirim($email, 'Selamat bergabung di aplikasi kami!');
return $user;
}
}Empat bagian penting dapat kita amati. Pertama, constructor hanya berisi dependency — service tidak menyimpan status miliknya sendiri, sehingga aman dipakai berkali-kali dari mana pun. Kedua, method publik adalah kata kerja bisnis: register(), bukan processData() atau doIt() — nama method merangkum use case. Ketiga, service mengembalikan objek domain (User) saat berhasil dan melempar exception saat aturan bisnis dilanggar. Keempat, satu method register() mengorkestrasi beberapa langkah — cek duplikat, buat user, kirim notifikasi — yang dulu akan menumpuk di controller.
Service, Model, atau Controller #
Pertanyaan “kode ini taruhnya di mana?” punya tiga kandidat, dan masing-masing punya urusan sendiri:
| Kandidat | Urusannya | Bukan urusannya |
|---|---|---|
| Controller | Menerjemahkan HTTP: validasi masuk, response keluar | Logika bisnis, query kompleks |
| Model | Data dan aturan data satu entitas | Orkestrasi lintas entitas, kirim email |
| Service | Use case: langkah-langkah satu operasi bisnis | Validasi HTTP, membangun response |
Ketiga batas ini sama pentingnya. Masukkan semua logika ke model dan kita mendapat fat model — User yang tahu cara mendaftarkan dirinya, mengirim email, dan mencatat analytics sekaligus; setiap perubahan aturan menyentuh satu kelas raksasa yang dipakai semua orang. Masukkan ke controller dan logika terkunci di balik HTTP seperti yang kita lihat di chapter pemisahan concern. Service berdiri di tengah: cukup dekat ke domain untuk membicarakan User dan Hash, cukup jauh dari HTTP untuk tidak mengenal Request.
Konvensi penempatannya sederhana: folder app/Services, satu kelas per berkas, nama kelas kata kerja atau peran (RegisterUser, PostPublisher, PembayarTagihan). Aplikasi yang lebih besar bisa memilih struktur per domain — app/Domain/Blog/... — dan pilihan tersebut kita bahas di chapter Domain Organization
.
Service Tanpa Status dan DTO #
Service idealnya stateless: dua pemanggilan register() tidak saling memengaruhi. Status sesaat berpindah ke parameter dan nilai kembali. Ketika kumpulan parameter mulai membesar — pendaftaran lengkap dengan alamat, preferensi, kode referral — bungkus dengan DTO (data transfer object) sehingga method tetap rapi:
<?php
namespace App\Dto;
final readonly class DataPendaftaran
{
public function __construct(
public string $nama,
public string $email,
public string $password,
) {}
}DTO adalah tas data tanpa perilaku: readonly menjamin isinya tak berubah setelah dibuat, dan tipe eksplisit menggantikan array asosiatif yang mudah salah ketik. Service menerima DataPendaftaran, mengembalikan objek domain atau DTO hasil, dan biarkan controller yang mengubahnya menjadi response.
Satu Kode, Tiga Pintu Masuk #
Kekuatan sesungguhnya service terlihat saat pemakaian ulang. Controller tinggal menvalidasi lalu mendelegasikan:
<?php
namespace App\Http\Controllers;
use App\Services\RegisterUser;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class RegisterUserController extends Controller
{
public function __construct(
protected RegisterUser $register,
) {}
public function simpan(Request $request): RedirectResponse
{
$data = $request->validate([
'nama' => 'required|string|max:100',
'email' => 'required|email',
'password' => 'required|string|min:8',
]);
$user = $this->register->register(
$data['nama'], $data['email'], $data['password'],
);
return redirect()
->route('register.sukses')
->with('status', "Selamat bergabung, {$user->name}!");
}
}Queue job memanggil method yang sama lewat handle():
<?php
namespace App\Jobs;
use App\Services\RegisterUser;
class DaftarPenggunaJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public string $nama,
public string $email,
public string $password,
) {}
public function handle(RegisterUser $register): void
{
$register->register($this->nama, $this->email, $this->password);
}
}Artisan command pun demikian:
<?php
namespace App\Console\Commands;
use App\Services\RegisterUser;
use Illuminate\Console\Command;
class DaftarPenggunaCommand extends Command
{
protected $signature = 'user:daftar {nama} {email} {password}';
protected $description = 'Daftarkan pengguna baru lewat service RegisterUser';
public function handle(RegisterUser $register): int
{
$user = $register->register(
$this->argument('nama'),
$this->argument('email'),
$this->argument('password'),
);
$this->info("Pengguna {$user->name} terdaftar (ID: {$user->id})");
return self::SUCCESS;
}
}Satu feature test menguji ketiganya sekaligus — tiga pintu, satu perilaku, tanpa satu baris logika pun yang diduplikasi:
<?php
// tests/Feature/RegisterUserServiceTest.php — tiga dari lima test di berkas ini
public function test_service_dipanggil_dari_controller(): void
{
$response = $this->post('/register', [
'nama' => 'Budi',
'email' => '[email protected]',
'password' => 'rahasia-kuat-123',
]);
$response->assertRedirectToRoute('register.sukses');
$response->assertSessionHas('status', 'Selamat bergabung, Budi!');
$this->assertDatabaseHas('users', ['name' => 'Budi', 'email' => '[email protected]']);
}
public function test_service_dipanggil_dari_job(): void
{
dispatch_sync(new DaftarPenggunaJob('Siti', '[email protected]', 'rahasia-kuat-123'));
$this->assertDatabaseHas('users', ['name' => 'Siti', 'email' => '[email protected]']);
}
public function test_service_dipanggil_dari_command(): void
{
$this->artisan('user:daftar', [
'nama' => 'Budi',
'email' => '[email protected]',
'password' => 'rahasia-kuat-123',
])
->expectsOutputToContain('Pengguna Budi terdaftar (ID: 1)')
->assertSuccessful();
$this->assertDatabaseHas('users', ['email' => '[email protected]']);
}Output yang Dihasilkan:
PASS Tests\Feature\RegisterUserServiceTest
✓ service bisa dites langsung tanpa http 0.20s
✓ service menolak email ganda 0.02s
✓ service dipanggil dari controller 0.05s
✓ service dipanggil dari job 0.01s
✓ service dipanggil dari command 0.01s
Tests: 5 passed (13 assertions)
Duration: 0.39sMenguji Service Tanpa HTTP #
Test pertama pada output di atas layak diperhatikan: service dites langsung tanpa request, route, maupun browser. Test double PusherRekaman menggantikan notifikasi asli lewat $this->instance() — teknik dari chapter DI — sehingga kita bisa memastikan notifikasi terkirim tanpa benar-benar mengirimnya:
class PusherRekaman implements PusherNotifikasi
{
/** @var array<int, string> */
public array $penerima = [];
public function kirim(string $penerima, string $pesan): string
{
$this->penerima[] = $penerima;
return 'tercatat';
}
}
public function test_service_bisa_dites_langsung_tanpa_http(): void
{
$rekaman = new PusherRekaman;
$this->instance(PusherNotifikasi::class, $rekaman);
$user = $this->app->make(RegisterUser::class)
->register('Budi', '[email protected]', 'rahasia-kuat-123');
$this->assertSame('Budi', $user->name);
$this->assertSame(['[email protected]'], $rekaman->penerima);
}Kecepatan test seperti ini adalah bonus, bukan tujuannya. Yang utama: kegagalan test menunjuk tepat ke layer yang salah — assertion user bermasalah berarti logika service yang rusak, bukan routing, middleware, atau view. Berbekal dokumentasi container untuk injeksi dependency-nya, service class siap menjadi tempat berlabuh logika bisnis.
Ada satu kelompok kode yang kadang muncul di sekitar service: lapisan akses data bernama repository. Apakah pola tersebut layak diterapkan di Laravel, atau justru menambah kompleksitas tanpa imbalan? Kita bedah dengan jujur di chapter berikutnya.