# Best Practice Laravel

Panduan konvensi kode untuk **semua project** dengan tech stack: **Laravel 13 (PHP 8.3)**, **Inertia + React + TypeScript**, **Laravel Wayfinder**, **Spatie Permission**, **Pest**, **Larastan**, **Laravel Pint**.

Dokumen ini bersifat reusable antar-project — jangan isi dengan nama domain/fitur spesifik satu project saja. Kalau sebuah project punya pengecualian dari aturan di sini, catat pengecualiannya di `CLAUDE.md`/README project tersebut, bukan mengubah dokumen ini.

Semua kode baru (controller, service, request, model, enum, komponen React) **wajib** mengikuti dokumen ini. Saat mengubah kode lama yang menyimpang, samakan dengan konvensi di sini sekalian (boy scout rule), kecuali perubahan di luar scope task yang sedang dikerjakan.

---

## 1. Konsistensi Struktur & Penamaan

- **Nama method harus konsisten** untuk fungsi yang setara. Jangan sampai controller A punya `store()` tapi controller B untuk hal yang sama pakai `save()`.
- **Urutan method harus konsisten** di semua Controller, Service, Model, dan class lain. Urutan baku untuk resource controller:
  1. `index()`
  2. `create()` (jika ada, hanya untuk non-Inertia modal-less form)
  3. `store()`
  4. `show()` (jika ada)
  5. `edit()` (jika ada)
  6. `update()`
  7. `destroy()`
  8. Method kustom lain (`updateStatus()`, `resetPassword()`, dll) **selalu ditaruh setelah** ketujuh method di atas, bukan disisipkan di tengah.

  Contoh salah — method kustom nyempil di antara `update()` dan `destroy()`:
  ```php
  index(), create(), store(), edit(), update(), resetPassword(), destroy()
  ```
  Contoh benar:
  ```php
  index(), create(), store(), edit(), update(), destroy(), resetPassword()
  ```
- **Organisasi Controller/Request/Service by domain.** Kelompokkan berdasarkan domain bisnis project yang bersangkutan, konsisten di ketiga layer:
  ```
  app/Http/Controllers/{Domain}/{Name}Controller.php
  app/Http/Requests/{Domain}/{Name}Request.php
  app/Services/{Domain}/{Name}Service.php
  ```
  Domain ditentukan oleh kebutuhan project (mis. `Manage`, `Master`, `Finances`, `Users`), bukan nama fitur individual. Class baru yang berhubungan masuk ke domain yang sudah ada; jangan bikin domain baru untuk satu fitur kecil.
- Ikuti konvensi penamaan Laravel community (PSR-12 + konvensi umum):

  | Yang di-nama-i | Gaya | Contoh benar | Contoh salah |
  |---|---|---|---|
  | Controller | singular | `ArticleController` | `ArticlesController` |
  | Route (URI) | plural | `articles/1` | `article/1` |
  | Route name | snake_case + dot | `users.show_active` | `users.show-active` |
  | Model | singular | `User` | `Users` |
  | Relasi hasOne/belongsTo | singular | `articleComment` | `articleComments` |
  | Relasi lain (hasMany, dll) | plural | `articleComments` | `articleComment` |
  | Tabel | plural, snake_case | `article_comments` | `articleComments` |
  | Tabel pivot | singular, alfabetis | `article_user` | `user_article` |
  | Kolom tabel | snake_case, tanpa nama model | `meta_title` | `article_meta_title` |
  | Foreign key | singular model + `_id` | `article_id` | `id_article` |
  | Primary key | - | `id` | `custom_id` |
  | Migration | deskriptif | `2017_01_01_000000_create_articles_table` | `2017_01_01_000000_articles` |
  | Method | camelCase | `getAll()` | `get_all()` |
  | Method resource controller | verba standar | `store()` | `saveArticle()` |
  | Method test (Pest) | deskriptif, `it(...)`/`test(...)` | `it('rejects guest from viewing article')` | — |
  | Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` |
  | Collection | deskriptif, plural | `$activeUsers` | `$data` |
  | Object tunggal | deskriptif, singular | `$activeUser` | `$obj` |
  | File config/lang index | snake_case | `articles_enabled` | `ArticlesEnabled` |
  | File view Blade | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` |
  | Komponen React (Inertia page/props) | PascalCase file, camelCase prop | `EditForm.tsx`, `isOpen` | — |
  | Contract (interface) | adjective/noun, tanpa prefix `I` | `AuthenticationInterface` | `IAuthentication` |
  | Trait | adjective | `Notifiable` | `NotificationTrait` |
  | Enum | singular | `UserType` | `UserTypeEnum` |
  | FormRequest | singular + `Request` | `UpdateUserRequest` | `UserFormRequest` |
  | Seeder | singular | `UserSeeder` | `UsersSeeder` |

---

## 2. Enum

- Semua data opsi yang sudah pasti/tetap valuenya **wajib** pakai native PHP Enum (`enum ... : string`), jangan pakai konstanta class atau string mentah.
- Setiap Enum yang punya representasi UI **wajib** menyediakan method `label(): string` berbahasa Indonesia:
  ```php
  enum OrderStatus: string
  {
      case Pending = 'pending';
      case Completed = 'completed';

      public function label(): string
      {
          return match ($this) {
              self::Pending => 'Menunggu',
              self::Completed => 'Selesai',
          };
      }
  }
  ```
- Kalau project punya banyak Enum, buat trait bantu (mis. `HasValues`) untuk method umum seperti `values()`/`options()` supaya tidak duplikat logika di tiap enum.
- Validasi enum di FormRequest pakai `Rule::enum(XxxEnum::class)`, bukan `Rule::in([...])` manual — termasuk kalau ditulis sebagai `Rule::in(XxxEnum::values())`, itu tetap salah karena tidak mendapat pesan error bawaan enum dan gampang lolos review karena polanya mirip valid:
  ```php
  // Bad
  'status' => ['required', 'string', Rule::in(OrderStatus::values())],

  // Good
  'status' => ['required', 'string', Rule::enum(OrderStatus::class)],
  ```
  `Rule::in([...])` tetap sah dipakai untuk daftar nilai tetap yang **belum** dijadikan Enum (mis. sedang dipertimbangkan atau memang bukan kandidat Enum) — tapi begitu ada Enum untuk field itu, wajib pindah ke `Rule::enum()`.
- Kirim enum ke frontend sebagai `value` + `label`, jangan kirim instance PHP mentah ke Inertia props — gunakan `->value` dan `->label()` secara eksplisit atau resource/mapper kecil.

---

## 3. Model & Eloquent

- Gunakan `#[Guarded(['id'])]` (attribute), **bukan** `protected $guarded` atau `#[Fillable]`. Tambahkan kolom lain ke `Guarded` hanya jika memang tidak boleh diisi lewat mass assignment (mis. `last_login_at`).
- **Hapus model/class yang sudah tidak dipakai**, jangan dibiarkan menumpuk. Cek dengan grep nama class-nya (bukan cuma teks labelnya) sebelum menyimpulkan tidak terpakai — model kosong tanpa `#[Guarded]` dan tanpa referensi pemakaian adalah tanda kuat dead code.
- **Selalu buat relasi dua arah.** Kalau `Order belongsTo Customer`, maka `Customer` juga harus punya relasi baliknya (`hasOne`/`hasMany` sesuai kardinalitas). Jangan biarkan relasi hanya berjalan satu arah.
- **Jangan asumsikan foreign key default Eloquent tanpa mengecek migration.** Sebelum menulis `hasMany`/`hasOne`/`belongsTo` tanpa parameter FK eksplisit, pastikan kolom hasil konvensi (`{model}_id`) memang ada di tabel terkait. Kalau kolomnya berbeda (mis. relasi menyeberang lewat model perantara), gunakan FK eksplisit atau `hasManyThrough`, bukan dibiarkan salah diam-diam.
- **Urutkan method relasi dalam satu model per kelompok tipe, lalu alfabetis di dalam tiap kelompok**, dengan urutan kelompok: `belongsTo` → `hasOne` → `hasMany` → `belongsToMany` → `hasOneThrough`/`hasManyThrough` → relasi morph (`morphTo`/`morphMany`/`morphToMany`). Method non-relasi (`casts()`, accessor, method bisnis custom) tetap di posisi semula relatif terhadap blok relasi.
  ```php
  // Good — dikelompokkan per tipe, lalu alfabetis
  public function department(): BelongsTo { ... }   // belongsTo
  public function user(): BelongsTo { ... }

  public function profile(): HasOne { ... }          // hasOne

  public function attendances(): HasMany { ... }     // hasMany
  public function submissions(): HasMany { ... }

  public function departments(): BelongsToMany { ... } // belongsToMany
  ```
- Eloquent-first, hindari raw query/`DB::` kecuali untuk kasus performa spesifik yang benar-benar butuh (agregasi berat, bulk update) — dan beri komentar alasannya.
- **Cegah N+1**: selalu eager-load relasi yang dipakai di view/Inertia props dengan `with()`/`load()`. Saat memakai partial eager load (`with('term:id,start_date,end_date')`), pastikan kolom yang dipakai untuk cast/accessor ikut disertakan, atau serialisasi akan error.
- Mass assignment lewat relasi, bukan set atribut manual satu-satu:
  ```php
  // Bad
  $article = new Article;
  $article->title = $request->title;
  $article->category_id = $category->id;
  $article->save();

  // Good
  $category->articles()->create($request->validated());
  ```
- Untuk data besar (export, batch update, notifikasi massal), gunakan `chunk()`/`chunkById()`/`cursor()`, jangan `get()` lalu `foreach` penuh di memori:
  ```php
  // Bad
  foreach (User::all() as $user) { ... }

  // Good
  User::chunkById(500, function ($users) {
      foreach ($users as $user) { ... }
  });
  ```
- Query builder singkat & ekspresif:

  | Panjang | Singkat |
  |---|---|
  | `->where('column', '=', 1)` | `->where('column', 1)` |
  | `->orderBy('created_at', 'desc')` | `->latest()` |
  | `->orderBy('created_at', 'asc')` | `->oldest()` |
  | `->select('id', 'name')->get()` | `->get(['id', 'name'])` |
  | `->first()->name` | `->value('name')` |

  Pola `->get([...])`/`->paginate($perPage, [...])` ini berlaku juga walau ada `with()`/`when()`/`join()`/dll di antara — Eloquent tidak peduli di posisi mana `select()` dipanggil relatif ke klausa lain, cuma peduli klausa itu ada sebelum eksekusi. **Kecuali** kalau query yang sama pakai `withCount()`/`withSum()`/`withAvg()`/`withMax()`/`withMin()`: method-method itu diam-diam menyuntik `select(table.*)` kalau belum ada `select()` eksplisit sebelumnya, dan Eloquent hanya menerapkan kolom dari `get($columns)`/`paginate($perPage, $columns)` kalau belum ada `select()` — begitu `withCount()` lebih dulu mengisi kolom, argumen kolom di method terminal **diabaikan diam-diam tanpa error** (balik jadi `select(*)`, bocor semua kolom). Jadi kalau ada `withCount()`/`withSum()`/dst di query, `select([...])` eksplisit **wajib** tetap dipertahankan sebelum pemanggilan `withCount()`/dst, jangan dipindah ke method terminal.

---

## 4. Controller, Request, dan Service

- **Controller** hanya mengatur alur request → response (validasi input dipanggil, service dipanggil, redirect/Inertia render dikembalikan). **Tidak boleh** ada query Eloquent kompleks atau business logic langsung di controller.
- **Selalu gunakan FormRequest**, sekecil apapun validasinya — jangan validasi inline di controller dengan `$request->validate()`.
- Business logic (kalkulasi, orkestrasi antar model, side effect seperti notifikasi/log) **disimpan di Service**, bukan di controller atau model.
- **Kalau method Service butuh user yang sedang login untuk scoping/filtering** (mis. dosen cuma lihat kelasnya sendiri, mahasiswa cuma lihat jurusannya sendiri), terima sebagai parameter eksplisit `User $user` (non-nullable, tanpa default) di **posisi pertama** — jangan panggil `auth()->user()` langsung di dalam Service. Controller yang menyuplainya lewat `$request->user()`. Ini soal testability (Service tidak bergantung diam-diam ke global state) dan konsistensi lintas Service.
  ```php
  // Bad
  public function paginated(int $perPage = 25): LengthAwarePaginator
  {
      $user = auth()->user();
      // ...
  }

  // Good
  public function paginated(User $user, int $perPage = 25): LengthAwarePaginator
  {
      // ...
  }
  ```
- Otorisasi berbasis permission (Spatie) dicek di dua tempat:
  - Route-level: middleware `permission:create-xxx` dipasang per-route/per-group.
  - Object-level (mis. user hanya boleh mengubah record miliknya sendiri): di `authorize()` milik FormRequest, kombinasikan `$this->user()->can('permission-name')` dengan pengecekan kepemilikan record. Untuk route tanpa FormRequest (mis. `destroy()` yang tidak butuh validasi input), pengecekan yang sama dilakukan inline dengan `abort_if()`/`abort_unless()` di Controller.
  - **Predikat kepemilikan itu sendiri ditaruh sebagai method di Model (`User`, atau model pemilik lain yang relevan), bukan didefinisikan ulang di tiap Controller/FormRequest yang butuh.** Satu aturan bisnis harus punya satu sumber kebenaran — supaya konsisten dan gampang di-test. Penamaan: `is<Peran>Of($target)` untuk peran yang punya nama (mis. `isAdvisorOf`), atau `canManage<Model>($target)` untuk gate umum "boleh mengubah/menghapus record ini".
    ```php
    // Bad — predikat yang sama diulang di FormRequest DAN Controller
    // (FormRequest)
    public function authorize(): bool
    {
        $assignment = $this->route('assignment');
        return $this->user()->can('update-assignments')
            && (! $this->user()->hasRole('dosen') || $assignment->courseClass->lecturer_id === $this->user()->lecturer?->id);
    }
    // (Controller::destroy(), butuh predikat yang sama karena tidak ada FormRequest)
    private function abortUnlessLecturerOwnsAssignment(Assignment $assignment): void
    {
        $user = request()->user();
        abort_if($user->hasRole('dosen') && $assignment->courseClass?->lecturer_id !== $user->lecturer?->id, 403);
    }

    // Good — predikat di Model, dipakai dari FormRequest maupun Controller
    // (User model)
    public function canManageAssignment(Assignment $assignment): bool
    {
        if ($this->hasRole(UserRole::Dosen->value)) {
            return $assignment->courseClass->lecturer_id === $this->lecturer?->id;
        }
        return true;
    }
    // (FormRequest)
    public function authorize(): bool
    {
        return $this->user()->can('update-assignments')
            && $this->user()->canManageAssignment($this->route('assignment'));
    }
    // (Controller::destroy())
    abort_unless(request()->user()->canManageAssignment($assignment), 403);
    ```
- Constructor injection untuk dependency (Service, Model), jangan `new Xxx` langsung di dalam method:
  ```php
  // Bad
  $user = new User;
  $user->create($request->validated());

  // Good
  public function __construct(protected UserService $userService) {}

  $this->userService->create($request->validated());
  ```
- **Single Responsibility** — satu method cuma ngerjain satu hal:
  ```php
  // Bad
  public function update(Request $request): string
  {
      $validated = $request->validate([...]);
      foreach ($request->events as $event) {
          $date = $this->carbon->parse($event['date'])->toString();
          $this->logger->log('Update event ' . $date);
      }
      $this->event->updateGeneralEvent($request->validated());
      return back();
  }

  // Good
  public function update(UpdateEventRequest $request): RedirectResponse
  {
      $this->logService->logEvents($request->events);
      $this->eventService->updateGeneralEvent($request->validated());
      return back();
  }
  ```
- Pecah method yang melakukan banyak hal jadi beberapa method kecil dengan nama deskriptif:
  ```php
  // Bad
  public function getFullNameAttribute(): string
  {
      if (auth()->user() && auth()->user()->hasRole('client') && auth()->user()->isVerified()) {
          return 'Mr. ' . $this->first_name . ' ' . $this->last_name;
      }
      return $this->first_name[0] . '. ' . $this->last_name;
  }

  // Good
  public function getFullNameAttribute(): string
  {
      return $this->isVerifiedClient() ? $this->getFullNameLong() : $this->getFullNameShort();
  }

  public function isVerifiedClient(): bool { ... }
  public function getFullNameLong(): string { ... }
  public function getFullNameShort(): string { ... }
  ```
- Return type selalu dideklarasikan secara eksplisit (`: Response`, `: RedirectResponse`, `: Collection`, dll).
- Untuk operasi yang menyentuh >1 tabel sekaligus (mis. buat record + update counter terkait), bungkus dengan `DB::transaction()` di dalam Service, supaya atomik.

---

## 5. Kode Ringkas & Idiomatis Laravel

Gunakan helper singkat yang sudah tersedia, jangan syntax panjang manual:

| Syntax panjang | Syntax ringkas |
|---|---|
| `Session::get('cart')` | `session('cart')` |
| `$request->session()->get('cart')` | `session('cart')` |
| `Session::put('cart', $data)` | `session(['cart' => $data])` |
| `$request->input('name')` | `$request->name` / `request('name')` |
| `return Redirect::back()` | `return back()` |
| `is_null($obj->relation) ? null : $obj->relation->id` | `$obj->relation?->id` |
| `return view('index')->with('title', $t)->with('client', $c)` | `return view('index', compact('title', 'client'))` |
| `$request->has('value') ? $request->value : 'default'` | `$request->get('value', 'default')` |
| `Carbon::now()`, `Carbon::today()` | `now()`, `today()` |
| `App::make('Class')` | `app('Class')` |

- **Jangan panggil `env()` di luar file config.** Simpan dulu ke `config/*.php`, lalu akses lewat `config('nama.key')`. Ini berlaku juga untuk kredensial/flag baru yang ditambahkan.
- **DocBlock boleh dipakai kalau memang dibutuhkan dan penting** — bukan default yang dipasang di semua method. Untuk method standar (CRUD biasa, atau apa pun yang sudah jelas maksudnya dari nama method + return/param type PHP), **jangan** tambah DocBlock — itu pemborosan. DocBlock baru layak ditulis untuk kasus yang memang penting, misalnya:
  1. **Generic type** yang tidak bisa diekspresikan native PHP, mis. `@return Collection<int, Student>`, `@param array<int, string>`.
  2. **Perilaku non-obvious** yang bisa bikin pembaca salah paham kalau tidak dijelaskan: aturan bisnis tersembunyi, constraint yang tidak kelihatan dari nama method, workaround keterbatasan interface/library.

  ```php
  // Bad — DocBlock generik yang cuma mengulang nama method, tidak nambah informasi
  /**
   * Get the validation rules that apply to the request.
   */
  public function rules(): array { ... }

  // Good — tanpa DocBlock sama sekali, karena nama + return type sudah cukup jelas
  public function rules(): array { ... }

  // Good — DocBlock dipertahankan karena memang menjelaskan hal yang tidak
  // kelihatan dari signature method (fallback logic yang bisa mengejutkan)
  /**
   * Falls back to the one with the latest start date if none is explicitly active.
   */
  public function getActive(): ?AcademicTerm { ... }
  ```
- Komentar kode hanya untuk menjelaskan **kenapa**, bukan **apa** — kalau nama variabel/method sudah jelas, jangan tambah komentar.

---

## 6. Frontend (Inertia + React + TypeScript)

- **Selalu jalankan `php artisan wayfinder:generate --with-form`** setelah menambah/mengubah route, controller, atau FormRequest — flag `--with-form` wajib, tanpa itu halaman auth/settings gagal `tsc`.
- Panggil route lewat Wayfinder helper yang di-generate (`import { store } from '@/routes/...'`), **jangan** hardcode string URL di komponen React.
- Props yang dikirim dari controller ke halaman Inertia harus sudah dalam bentuk final untuk UI (enum sudah di-`value`/`label`, tanggal sudah diformat/di-ISO-kan) — jangan lempar model Eloquent mentah tanpa transformasi eksplisit.
- Komponen React: file `PascalCase.tsx`, satu komponen per file untuk komponen yang di-export dan dipakai di tempat lain; komponen kecil khusus halaman boleh co-located di file yang sama.
- Tipe untuk data dari backend didefinisikan eksplisit (interface/type), jangan `any`.

---

## 7. Testing

- Test ditulis dengan **Pest**, bukan gaya PHPUnit class-based, kecuali menyentuh kode lama yang belum dimigrasikan.
- Nama test deskriptif dan menyatakan perilaku, bukan nama method: `it('rejects guest from updating another user's profile')`.
- Untuk endpoint yang dilindungi permission, test **minimal** mencakup: pengguna dengan permission (berhasil), pengguna tanpa permission (403), dan — kalau ada object-level authorization — kasus user lain yang mencoba mengakses record bukan miliknya (ditolak).
- Gunakan factory (`Model::factory()`), jangan insert manual ke DB di test.
- Jalankan `php artisan test` (atau `vendor/bin/pest`) sebelum menganggap task selesai jika ada perubahan pada Service/Controller/Request.

---

## 8. Kualitas Kode & Tooling

- Jalankan **Laravel Pint** (`vendor/bin/pint --parallel`) sebelum commit — style harus konsisten, jangan format manual.
- Jalankan **Larastan** (`vendor/bin/phpstan analyse`) untuk perubahan yang menyentuh tipe/return value; perbaiki temuannya, jangan suppress kecuali benar-benar false positive dan beri alasan.
- Jangan gunakan `@phpstan-ignore` / `@ts-ignore` sebagai jalan pintas — perbaiki akar masalah tipe-nya.

---

## 9. Keamanan

- Validasi selalu di FormRequest (lihat §4), termasuk validasi kepemilikan record lewat `Rule::exists()` yang di-scope, bukan hanya cek ID ada di tabel mana pun.
- Jangan expose data sensitif (password hash, token) lewat Inertia props atau API resource — pastikan `Guarded`/`hidden` pada model sudah benar dan props controller hanya kirim field yang dibutuhkan.
- Upload file lewat satu service upload terpusat per project, jangan tulis logic upload baru per fitur — validasi mime/size selalu di FormRequest terkait.
- Permission baru yang ditambahkan harus terdaftar di seeder permission (Spatie) dan dipasang di route lewat `permission:` middleware.
