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