itmpwk.ac.id/BEST_PRACTICE.md

355 lines
21 KiB
Markdown

# 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.