Arsitektur & Best Practices — Laravel

Mengorganisir kode Laravel untuk project yang scalable. Service Pattern: // App/Services/OrderService.php class OrderService { public function place(User…

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@store dari 30 baris jadi 6 baris, dengan memindahkan isinya ke tiga class baru. Setelah dirapikan, respons POST /loans dengan 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;
    }
}

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

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().

Yang akan kamu pelajari