Arsitektur di sini artinya aturan "kode apa tinggal di mana", supaya tiap class punya satu tugas. Ingat dapur restoran di lesson Apa itu Laravel?: pelayan gak ikut memasak, gak mengecek gudang, dan gak menata piring. Ada yang mengecek pesanan, ada koki, ada bagian penyajian. Kalau pelayan mengerjakan semuanya, dia lambat, gampang salah, dan susah digantikan. Controller yang mengerjakan semuanya juga begitu.
🧪 Tebakan cepat: kita akan merapikan
LoanController@storedari 30 baris jadi 6 baris, dengan memindahkan isinya ke tiga class baru. Setelah dirapikan, responsPOST /loansdengan body yang sama: berubah, atau persis sama? Tebak dulu, jawabannya ada di bagian "Hasilnya".
Controller gemuk
Ini store() untuk meminjam buku di app Perpustakaan, ditulis semuanya di satu tempat:
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'book_id' => ['required', 'exists:books,id'],
]);
$book = Book::findOrFail($data['book_id']);
if ($book->stock < 1) {
throw ValidationException::withMessages(['book_id' => 'Stok buku ini sedang habis.']);
}
$loan = DB::transaction(function () use ($request, $book) {
$book->decrement('stock');
return Loan::create([
'user_id' => $request->user()->id,
'book_id' => $book->id,
'due_at' => now()->addDays(14),
]);
});
BookBorrowed::dispatch($loan);
return response()->json(['data' => [
'id' => $loan->id,
'book' => $book->title,
'member' => $request->user()->name,
'due_at' => $loan->due_at->toDateString(),
]], 201);
}
Kode ini jalan dan benar. Masalahnya, satu method tahu lima hal sekaligus: HTTP (request dan status 201), aturan input (book_id wajib dan harus ada), aturan bisnis (stok harus ada, pinjam 14 hari), database (transaksi, decrement, create), dan bentuk JSON. Kalau admin mau mencatat peminjaman lewat command Artisan, logika ini gak bisa dipakai, karena terkunci di method yang butuh request HTTP. DB::transaction di sini memastikan stok berkurang dan peminjaman tercatat bersama-sama, atau gak sama sekali.
Langkah 1: validasi ke FormRequest
php artisan make:request StoreLoanRequest (lesson Validasi). Laravel menjalankan aturannya sebelum controller dipanggil, dan menjawab 422 sendiri kalau gagal.
class StoreLoanRequest extends FormRequest
{
public function authorize(): bool
{
return true; // siapa yang boleh: lesson Policies & Authorization
}
public function rules(): array
{
return ['book_id' => ['required', 'exists:books,id']];
}
}
Controller cukup mengganti Request dengan StoreLoanRequest dan membaca $request->validated('book_id'). Hati-hati: file buatan make:request berisi authorize() yang mengembalikan false. Kalau lupa diubah, semua request dijawab 403 | This action is unauthorized.
Langkah 2: aturan bisnis ke action class
Buat app/Actions/BorrowBook.php (php artisan make:class Actions/BorrowBook):
class BorrowBook
{
public function execute(User $member, Book $book): Loan
{
if ($book->stock < 1) {
throw ValidationException::withMessages(['book_id' => 'Stok buku ini sedang habis.']);
}
$loan = DB::transaction(function () use ($member, $book) {
$book->decrement('stock');
return Loan::create(['user_id' => $member->id, 'book_id' => $book->id, 'due_at' => now()->addDays(14)]);
});
BookBorrowed::dispatch($loan);
return $loan;
}
}
execute(User $member, Book $book)menerima model, bukanRequest. Jadi controller, command Artisan, job antrean, dan test bisa memanggilnya.- Controller menerimanya lewat parameter
BorrowBook $borrowBook, dan service container yang membuatkannya (lesson Service Container). - Satu class satu aksi disebut action. Kalau beberapa aksi yang berkaitan dikumpulkan di satu class (
LoanServicedenganborrow(),return(),extend()), itu service, biasanya diapp/Services. Dua-duanya umum. Pilih satu dan konsisten.
Langkah 3: bentuk JSON ke Resource
php artisan make:resource LoanResource (lesson API Resource):
class LoanResource extends JsonResource
{
public function toArray(Request $request): array
{
return ['id' => $this->id, 'book' => $this->book->title, 'member' => $this->user->name, 'due_at' => $this->due_at->toDateString()];
}
}
Hasilnya
public function store(StoreLoanRequest $request, BorrowBook $borrowBook): LoanResource
{
$book = Book::findOrFail($request->validated('book_id'));
return new LoanResource($borrowBook->execute($request->user(), $book));
}
Dari 30 baris jadi 6. new LoanResource(...) membungkus data di "data" dan menjawab 201 sendiri, karena peminjamannya baru dibuat. Responsnya:
| Body request | Respons, sebelum dan sesudah dirapikan |
|---|---|
{"book_id": 1} |
201 {"data":{"id":12,"book":"Laskar Pelangi","member":"Budi","due_at":"2026-10-23"}} |
{"book_id": 99} |
422 {"message":"The selected book id is invalid.","errors":{"book_id":["The selected book id is invalid."]}} |
{"book_id": 2}, Bumi Manusia, stok 0 |
422 {"message":"Stok buku ini sedang habis.","errors":{"book_id":["Stok buku ini sedang habis."]}} |
Persis sama. Itu jawaban tebakannya. Refactor memindahkan kode, bukan mengubah perilakunya. Jalankan test-mu (lesson Testing) sesudah tiap langkah untuk membuktikannya.
| Lapisan | Boleh tahu | Gak perlu tahu |
|---|---|---|
| Controller | request masuk, respons keluar | stok, transaksi, bentuk JSON |
StoreLoanRequest |
aturan input | cara meminjam |
BorrowBook |
aturan bisnis, database, event | Request, response(), status HTTP |
LoanResource |
bentuk JSON | dari mana datanya |
Folder dan konfigurasi
| Jenis kode | Folder |
|---|---|
| Model Eloquent | app/Models |
| Controller, FormRequest, Resource | app/Http/Controllers, app/Http/Requests, app/Http/Resources |
| Action atau service | app/Actions, app/Services |
| Event, listener, job, policy | app/Events, app/Listeners, app/Jobs, app/Policies |
Nilai yang beda tiap server (nama database, password, QUEUE_CONNECTION) tinggal di file .env, bukan di kode. Bacanya pakai env(), hanya di file config/. Di kode lain, pakai config():
// config/perpustakaan.php
return [
'loan_days' => env('LOAN_DAYS', 14), // 14 kalau LOAN_DAYS gak ada di .env
];
// di BorrowBook
'due_at' => now()->addDays(config('perpustakaan.loan_days')),
env('DB_DATABASE', 'default') membaca DB_DATABASE dari .env, dan argumen kedua dipakai kalau nilainya gak ada. Di server, php artisan config:cache menyimpan semua config jadi satu file, dan sesudah itu .env gak dibaca lagi: env() di luar config/ mengembalikan null.
⚠️ Jebakan yang sering ketemu
- Action yang menerima
Request. Command dan job gak punya request, jadi action-nya gak bisa dipakai di sana. Oper model atau nilai biasa. - Merapikan sambil mengubah perilaku. Pindahkan satu langkah, jalankan test, baru lanjut. Kalau responsnya berubah, itu bukan refactor lagi.
env()di luar folderconfig/. Jalan di laptop,nulldi server setelahconfig:cache.- Lapisan untuk segalanya. CRUD 3 baris gak butuh action, service, dan repository. Eloquent sudah jadi lapisan database-mu. Tambah lapisan saat controller mulai gemuk atau logikanya perlu dipakai di tempat lain.
- Di latihan, route-nya gak memakai service. Logikanya di
OrderService, routeRoute::postcuma memanggilnya dan menjawab dengan status201.
TL;DR: Controller gemuk tahu terlalu banyak. Pecah satu langkah demi satu langkah: validasi ke FormRequest (make:request), aturan bisnis ke action atau service class (app/Actions, app/Services) yang menerima model, bukan Request, dan bentuk JSON ke Resource (make:resource). Controller tinggal menerima request dan mengembalikan respons, dan responsnya tetap persis sama. Model ada di app/Models, nilai per server ada di .env, dibaca dengan env('NAMA', 'default') hanya di config/, lalu dipakai lewat config().