18 KiB
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 pakaisave(). -
Urutan method harus konsisten di semua Controller, Service, Model, dan class lain. Urutan baku untuk resource controller:
index()create()(jika ada, hanya untuk non-Inertia modal-less form)store()show()(jika ada)edit()(jika ada)update()destroy()- 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()dandestroy():index(), create(), store(), edit(), update(), resetPassword(), destroy()Contoh benar:
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.phpDomain 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 ArticleControllerArticlesControllerRoute (URI) plural articles/1article/1Route name snake_case + dot users.show_activeusers.show-activeModel singular UserUsersRelasi hasOne/belongsTo singular articleCommentarticleCommentsRelasi lain (hasMany, dll) plural articleCommentsarticleCommentTabel plural, snake_case article_commentsarticleCommentsTabel pivot singular, alfabetis article_useruser_articleKolom tabel snake_case, tanpa nama model meta_titlearticle_meta_titleForeign key singular model + _idarticle_idid_articlePrimary key - idcustom_idMigration deskriptif 2017_01_01_000000_create_articles_table2017_01_01_000000_articlesMethod 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_authorCollection deskriptif, plural $activeUsers$dataObject tunggal deskriptif, singular $activeUser$objFile config/lang index snake_case articles_enabledArticlesEnabledFile view Blade kebab-case show-filtered.blade.phpshowFiltered.blade.phpKomponen React (Inertia page/props) PascalCase file, camelCase prop EditForm.tsx,isOpen— Contract (interface) adjective/noun, tanpa prefix IAuthenticationInterfaceIAuthenticationTrait adjective NotifiableNotificationTraitEnum singular UserTypeUserTypeEnumFormRequest singular + RequestUpdateUserRequestUserFormRequestSeeder singular UserSeederUsersSeeder
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(): stringberbahasa Indonesia: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 sepertivalues()/options()supaya tidak duplikat logika di tiap enum. - Validasi enum di FormRequest pakai
Rule::enum(XxxEnum::class), bukanRule::in([...])manual — termasuk kalau ditulis sebagaiRule::in(XxxEnum::values()), itu tetap salah karena tidak mendapat pesan error bawaan enum dan gampang lolos review karena polanya mirip valid:// 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 keRule::enum(). - Kirim enum ke frontend sebagai
value+label, jangan kirim instance PHP mentah ke Inertia props — gunakan->valuedan->label()secara eksplisit atau resource/mapper kecil.
3. Model & Eloquent
- Gunakan
#[Guarded(['id'])](attribute), bukanprotected $guardedatau#[Fillable]. Tambahkan kolom lain keGuardedhanya 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, makaCustomerjuga harus punya relasi baliknya (hasOne/hasManysesuai kardinalitas). Jangan biarkan relasi hanya berjalan satu arah. - Jangan asumsikan foreign key default Eloquent tanpa mengecek migration. Sebelum menulis
hasMany/hasOne/belongsTotanpa 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 atauhasManyThrough, 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.// 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:
// 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(), janganget()laluforeachpenuh di memori:// 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.
- 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 panggilauth()->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.// 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-xxxdipasang 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.
- Route-level: middleware
- Constructor injection untuk dependency (Service, Model), jangan
new Xxxlangsung di dalam method:// 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:
// 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:
// 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 keconfig/*.php, lalu akses lewatconfig('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:
- Generic type yang tidak bisa diekspresikan native PHP, mis.
@return Collection<int, Student>,@param array<int, string>. - 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.
// 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 { ... } - Generic type yang tidak bisa diekspresikan native PHP, mis.
-
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-formsetelah menambah/mengubah route, controller, atau FormRequest — flag--with-formwajib, tanpa itu halaman auth/settings gagaltsc. - 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(atauvendor/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-ignoresebagai 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/hiddenpada 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.