295 lines
16 KiB
Markdown
295 lines
16 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')` |
|
|
|
|
---
|
|
|
|
## 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.
|
|
- 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.
|
|
- 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.
|
|
- **Tidak pakai DocBlock** untuk mendeskripsikan fungsi. Gunakan nama method yang deskriptif + return type/param type hint dari PHP. DocBlock hanya boleh dipakai kalau memang dibutuhkan untuk generic type (`@return Collection<int, Student>`) yang tidak bisa diekspresikan native PHP — bukan untuk narasi.
|
|
```php
|
|
// Bad
|
|
/**
|
|
* The function checks if given string is a valid ASCII string
|
|
* @param string $string
|
|
* @return bool
|
|
*/
|
|
public function checkString($string) { }
|
|
|
|
// Good
|
|
public function isValidAsciiString(string $string): bool { }
|
|
```
|
|
- 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.
|