itmpwk.ac.id/BEST_PRACTICE.md

20 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 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():

    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.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:
    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:
    // 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: belongsTohasOnehasManybelongsToManyhasOneThrough/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(), jangan get() lalu foreach penuh 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 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.
    // 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".
      // 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:
    // 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 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.
    // 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.