dstpabuaran.com/.ai/CONVENTIONS.md

53 KiB

Code Conventions

Aturan-aturan yang WAJIB diikuti saat menulis kode.


Namespace & Import Convention

Backend → SELALU pakai use import di atas file

// ✅ SELALU import class di atas file, gunakan nama pendek di kode
use App\Models\Product;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;

#[Guarded(['id'])]
class Product extends Model
{
    #[Scope]
    protected function active(Builder $query): void { ... }
}

// ❌ JANGAN tulis namespace lengkap di deklarasi atribut/class
#[\Illuminate\Database\Eloquent\Attributes\Scope]  // JANGAN
protected function active(\Illuminate\Database\Eloquent\Builder $query): void { ... }

// ❌ JANGAN import di tengah file atau inline
class Product extends \App\Models\Model { ... }  // JANGAN

Aturan Import

// ✅ Urutan import (group by namespace):
// 1. PHP built-in (DateTime, etc)
// 2. Laravel/Framework (Illuminate\*)
// 3. App (App\Models, App\Services, etc)
// 4. Third-party (Spatie, etc)

// ✅ Untuk atribut PHP 8+ → gunakan short name
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Casts\Attribute;

#[Scope]      // ✅ short name
#[Appends]    // ✅ short name

// ✅ Untuk type hint → gunakan short name
public function __construct(
    private ProductService $service,  // ✅ short name
) {}

// ❌ JANGAN
public function __construct(
    private \App\Services\Admin\Master\Product\ProductService $service,  // JANGAN
) {}

Language Convention

Backend → English

Variabel, function, kolom database, class, method — SEMUA pakai English:

// ✅ Variable
$orderId, $totalAmount, $customerName

// ✅ Function/Method
public function getOrders(): Collection
public function calculateTotal(): int

// ✅ Database columns
$table->string('order_number');
$table->decimal('total_amount');

// ✅ Class names
class OrderService { ... }
class OrderRequest extends FormRequest { ... }

Frontend (UI) → Indonesian

Teks yang ditampilkan ke user — pakai Bahasa Indonesia:

// ✅ Labels
<Label>Nama Produk</Label>
<Label>Harga Jual</Label>

// ✅ Placeholders
<Input placeholder="Masukkan nama" />

// ✅ Buttons
<Button>Tambah</Button>
<Button>Simpan</Button>
<Button>Hapus</Button>

// ✅ Flash messages
'Produk berhasil ditambahkan'
'Data berhasil dihapus'

// ✅ Status labels (di Badge)
<OrderStatusBadge status={order.status} />  // "Menunggu", "Selesai"

// ✅ Page titles
<Head title="Daftar Produk" />
<Head title="Tambah Transaksi" />

Contoh Perbandingan

Elemen English (Backend) Indonesian (UI)
Variable $customerName
DB column customer_name
Method getCustomerName()
Label Nama Customer
Placeholder Masukkan nama
Button Simpan, Hapus
Flash Berhasil disimpan
Status OrderStatus::PENDING Menunggu

Model

Mass Assignment

// ✅ SELALU pakai #[Guarded(['id'])]
#[Guarded(['id'])]
class Product extends Model { ... }

// ❌ JANGAN pakai $fillable
protected $fillable = ['name', 'status']; // JANGAN

Urutan Isi Model

use Illuminate\Database\Eloquent\Casts\Attribute;

// Attributes di ATAS class declaration
#[Guarded(['id'])]
#[Appends(['formatted_name'])]
class Product extends Model
{
    use HasFactory, SoftDeletes;

    // 1. Casts (method, wajib untuk semua tipe yang perlu casting)
    protected function casts(): array
    {
        return [
            'status' => ProductStatus::class,
            'is_active' => 'boolean',
        ];
    }

    // 2. Scopes (order abjad, gunakan atribut #[Scope])
    #[Scope]
    protected function active(Builder $query): void
    {
        $query->where('is_active', true);
    }

    #[Scope]
    protected function draft(Builder $query): void
    {
        $query->where('status', ProductStatus::DRAFT);
    }

    // 3. Mutators & Accessors (order abjad, NAMA HARUS BERBEDA dari kolom DB)
    // TIDAK perlu accessor untuk label — Enum sudah handle via ->label()
    protected function formattedName(): Attribute
    {
        return Attribute::make(
            get: fn () => ucfirst($this->name),
        );
    }

    // 4. Relationships (order abjad, WAJIB 2 ARAH)
    public function categories(): BelongsToMany { ... }
    public function productVariants(): HasMany { ... }
}

// ✅ Trait stacking — gabungkan dengan koma
use HasFactory, SoftDeletes;  // 1 baris, pisah koma

Casting — Wajib untuk Semua Tipe

// ✅ Selalu cast kolom yang perlu — gunakan METHOD syntax
protected function casts(): array
{
    return [
        // Enum → gunakan enum class
        'status' => OrderStatus::class,
        'type' => CashTransactionType::class,

        // Currency/angka → integer (tanpa desimal)
        'amount' => 'integer',
        'price' => 'integer',
        'quantity' => 'integer',

        // Date → format Y-m-d
        'join_date' => 'date:Y-m-d',

        // Boolean
        'is_active' => 'boolean',
        'is_affiliate' => 'boolean',

        // Array (JSON)
        'marketplace_settings_snapshot' => 'array',
        'payload' => 'array',

        // DateTime
        'verified_at' => 'datetime',
        'paid_at' => 'datetime',
    ];
}

// ❌ JANGAN pakai property syntax
protected $casts = [...]; // JANGAN

// ❌ JANGAN biarkan kolom tanpa cast jika tipenya perlu
'amount' => 'integer',  // ✅ benar
// 'amount' => '',       // ❌ salah — tidak dicast

Enum untuk Data Opsi

// ✅ SELALU gunakan Enum untuk data yang punya opsi tetap
enum OrderStatus: string
{
    use HasValues;

    case PENDING = 'pending';
    case PROCESSING = 'processing';
    case COMPLETED = 'completed';
    case CANCELLED = 'cancelled';
    case REFUNDED = 'refunded';

    public function label(): string
    {
        return match ($this) {
            self::PENDING => 'Menunggu',
            self::PROCESSING => 'Diproses',
            self::COMPLETED => 'Selesai',
            self::CANCELLED => 'Dibatalkan',
            self::REFUNDED => 'Dikembalikan',
        };
    }
}

// ✅ JANGAN pakai string biasa untuk status
// ❌ 'status' => 'pending'  // tanpa enum
// ✅ 'status' => OrderStatus::PENDING  // dengan enum

Accessor & Mutator — Nama harus BERBEDA dari kolom DB

// ✅ Nama accessor TIDAK BOLEH sama dengan nama kolom di DB
// Format: prefix 'formatted_' atau suffix '_label'
// Gunakan Attribute::make(get: fn () => ...)

use Illuminate\Database\Eloquent\Casts\Attribute;

// Currency → prefix 'formatted_' + 'Rp ' prefix
protected function formattedAmount(): Attribute
{
    return Attribute::make(
        get: fn () => 'Rp ' . number_format($this->amount, 0, ',', '.'),
    );
}

// Number (bukan rupiah) → prefix 'formatted_'
protected function formattedStock(): Attribute
{
    return Attribute::make(
        get: fn () => number_format($this->stock, 0, ',', '.'),
    );
}

// Date → prefix 'formatted_' + format 'l, d F Y'
protected function formattedJoinDate(): Attribute
{
    return Attribute::make(
        get: fn () => $this->join_date?->translatedFormat('l, d F Y'),
    );
}

// DateTime → prefix 'formatted_' + format 'l, d F Y H:i'
protected function formattedCheckInAt(): Attribute
{
    return Attribute::make(
        get: fn () => $this->check_in_at?->translatedFormat('l, d F Y H:i'),
    );
}

// String → prefix 'formatted_'
protected function formattedName(): Attribute
{
    return Attribute::make(
        get: fn () => ucfirst($this->name),
    );
}

// Phone → prefix 'formatted_' (get + set)
protected function formattedPhoneNumber(): Attribute
{
    return Attribute::make(
        get: fn () => $this->phone_number
            ? preg_replace('/(\d{4})(?=\d)/', '$1 ', $this->phone_number)
            : null,
        set: fn ($value) => preg_replace('/\s/', '', $value),
    );
}

// Month name → suffix '_name'
protected function monthName(): Attribute
{
    return Attribute::make(
        get: fn () => $this->month ? Carbon::create()->month($this->month)->translatedFormat('F') : null,
    );
}

// Label → suffix '_label' (TIDAK pakai formatted_)
// TIDAK PERLU accessor manual — Enum sudah punya method label()
// Cukup panggil langsung: $this->status->label()
// Tidak perlu buat statusLabel() accessor

// ❌ JANGAN: amount() — bentrok dengan kolom 'amount'
// ❌ JANGAN: name() — bentrok dengan kolom 'name'
// ✅ BENAR: formattedAmount(), formattedName()

Accessor Format Rules

// ✅ Format yang WAJIB diikuti untuk accessor:

// 1. Date (kolom date) → 'l, d F Y'
//    Contoh: "Selasa, 25 Agu 2026"
$this->join_date?->translatedFormat('l, d F Y')

// 2. DateTime (kolom datetime) → 'l, d F Y H:i'
//    Contoh: "Selasa, 25 Agu 2026 12:12"
$this->check_in_at?->translatedFormat('l, d F Y H:i')

// 3. Currency (rupiah) → 'Rp ' + number_format(0, ',', '.')
//    Contoh: "Rp 1.000.000"
'Rp ' . number_format($this->amount, 0, ',', '.')

// 4. Number (angka biasa) → number_format(0, ',', '.')
//    Contoh: "1.000"
number_format($this->stock, 0, ',', '.')

// 5. Month name → Carbon translatedFormat('F')
//    Contoh: "Januari", "Februari"
Carbon::create()->month($this->month)->translatedFormat('F')

Appends — Data yang Ditampilkan

// ✅ Gunakan snake_case (sama dengan nama accessor)
#[Appends(['formatted_amount', 'formatted_join_date'])]

// ❌ JANGAN append kolom yang sudah ada di DB (hanya untuk accessor)
// ❌ Appends(['amount'])  // amount sudah ada di DB
// ✅ Appends(['formatted_amount'])  // formatted_amount adalah accessor

Scopes — untuk Data Opsi

// ✅ SELALU buat scope untuk data yang punya opsi
// Gunakan atribut #[Scope] dan return type void

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Attributes\Scope;

// Untuk enum
#[Scope]
protected function pending(Builder $query): void
{
    $query->where('status', OrderStatus::PENDING);
}

// Untuk boolean
#[Scope]
protected function active(Builder $query): void
{
    $query->where('is_active', true);
}

// ❌ JANGAN: public function pending(Builder $query): Builder
// ❌ JANGAN: scopePending (pakai prefix 'scope')
// ✅ BENAR: #[Scope] + protected function pending(Builder $query): void

Relationships — WAJIB 2 ARAH

// ✅ Setiap relasi HARUS ditulis di KEDUA model

// Model: User
public function userProfile(): HasOne
{
    return $this->hasOne(UserProfile::class);
}

// Model: UserProfile (2 ARAH!)
public function user(): BelongsTo
{
    return $this->belongsTo(User::class);
}

// ✅ Contoh relasi 2 arah:
// User ↔ UserProfile
// User ↔ Employee
// User ↔ CashAccount (created_by_id)
// CashAccount ↔ CashTransaction
// CashTransaction ↔ Expense (1:1)
// Order ↔ OrderItem
// Cutting ↔ CuttingMaterial
// dll.

// ❌ JANGAN hanya tulis di salah 1 model
// Jika ada User::userProfile(), maka HARUS ada UserProfile::user()

Relationship Naming

// BelongsTo → singular
user()          // → BelongsTo User
cashAccount()   // → BelongsTo CashAccount

// HasMany → plural
orders()        // → HasMany Order
payrolls()      // → HasMany Payroll

// HasOne → singular
userProfile()   // → HasOne UserProfile
employee()      // → HasOne Employee

// Custom FK → pastikan ada parameter
createdCuttings()   // → HasMany Cutting, 'created_by_id'

Eloquent Select & Eager Loading — Selalu Select yang Dibutuhkan

// ✅ SELALU select kolom yang dibutuhkan saja — gunakan array syntax
$orders = Order::select(['id', 'order_number', 'total_amount', 'status'])
    ->with(['customer:id,name', 'createdBy:id,username'])
    ->get();

// ✅ Eager load relasi yang dibutuhkan
$products = Product::with([
    'categories:id,name',
    'productVariants:id,product_id,name,stock',
])->get();

// ❌ JANGAN: Product::all() — load semua kolom + semua relasi
// ❌ JANGAN: Order::with(['customer', 'createdBy', 'orderItems', 'cashTransaction'])->get() — terlalu banyak relasi

// ✅ Pilih relasi yang benar-benar ditampilkan di view
$orders = Order::select(['id', 'order_number', 'total_amount', 'status', 'customer_id', 'created_by_id'])
    ->with([
        'customer:id,name',
        'createdBy:id,username',
    ])
    ->orderBy($sort, $direction)
    ->paginate($perPage);

Controller

SELALU Gunakan Return Type

// ✅ Return type wajib ada di SEMUA method — controller & service
// ✅ Response untuk controller, void/array/bool untuk service

ZONK — Tidak Ada Logic di Controller

// ✅ Controller HANYA mengatur lalu lintas
// Seluruh logic ADA DI SERVICE

class ProductController extends Controller
{
    public function __construct(
        private ProductService $service
    ) {}

    public function index(Request $request): Response
    {
        return Inertia::render('Admin/Master/Products/Index', [
            'products' => $this->service->paginated(
                perPage: $request->input('per_page', 25),
                search: $request->input('search', ''),
                sort: $request->input('sort', 'created_at'),
                direction: $request->input('direction', 'desc'),
            ),
        ]);
    }

    public function store(ProductRequest $request): Response
    {
        $this->service->store($request->validated());

        Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil ditambahkan']);

        return back();
    }
}

// ❌ JANGAN ada logic di controller, SEKECIL APAPUN
// ❌ $name = strtoupper($request->name);  // logic → pindah ke service
// ❌ if ($request->hasFile('photo')) { ... }  // logic → pindah ke service

// ✅ Jika ada yang看似simple seperti resetPassword, tetap pindah ke service
public function resetPassword(UserRequest $request, User $user): Response
{
    $this->service->resetPassword($user, $request->validated());

    Inertia::flash('toast', ['type' => 'success', 'message' => 'Password berhasil direset']);

    return back();
}

Urutan Method

class ProductController extends Controller
{
    // 1. __construct()
    public function __construct(private ProductService $service) {}

    // 2. index()
    public function index(Request $request): Response { ... }

    // 3. create()
    public function create(): Response { ... }

    // 4. store()
    public function store(ProductRequest $request): Response { ... }

    // 5. show() — jika ada
    public function show(Product $product): Response { ... }

    // 6. edit()
    public function edit(Product $product): Response { ... }

    // 7. update()
    public function update(ProductRequest $request, Product $product): Response { ... }

    // 8. destroy()
    public function destroy(Product $product): Response { ... }

    // 9. Custom actions (jika ada, JANGAN dipaksakan)
    public function toggleStatus(Product $product): Response { ... }
    public function resetPassword(UserRequest $request, User $user): Response { ... }
}

Return ke Halaman yang Sama — Gunakan back()

// ✅ SELALU gunakan Inertia::flash() sebelum return back()
public function destroy(Product $product)
{
    $this->service->destroy($product);

    Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil dihapus']);

    return back();
}

// ❌ JANGAN pakai ->with()
return back()->with('success', 'Produk berhasil dihapus');  // JANGAN

// ✅ Untuk error di handleAction
return $this->handleAction(
    fn () => $this->service->store($data),
    'Data berhasil ditambahkan',
    route('admin.master.products.index')
);
// handleAction sudah handle flash otomatis

// ✅ Untuk redirect ke halaman lain
return to_route('dashboard');  // ✅ hanya jika memang pindah halaman

Jangan Panggil Model Langsung — Gunakan Service

// ❌ JANGAN panggil Model langsung di Controller
public function index()
{
    $categories = Category::all();  // JANGAN
    return Inertia::render('...', compact('categories'));
}

// ✅ SELALU gunakan Service
public function index()
{
    $categories = $this->categoryService->getAll();  // ✅
    return Inertia::render('...', compact('categories'));
}

DB Transaction — Gunakan handleAction() untuk 2+ Query

// ✅ handleAction() → untuk 2+ query/operasi dalam 1 action
public function store(OrderRequest $request): Response
{
    return $this->handleAction(
        fn () => $this->service->store($request->validated()),
        'Transaksi berhasil ditambahkan',
        route('admin.manage.transactions.index')
    );
}

// ✅ handleAction() otomatis wrap dalam DB::transaction()
// ✅ Jika gagal, otomatis rollback + flash error

// ✅ 1 query → manual flash (tanpa handleAction)
public function destroy(Product $product): Response
{
    $this->service->destroy($product);

    Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil dihapus']);

    return back();
}

Service Property — Constructor Promotion

// ✅ Gunakan constructor promotion
public function __construct(
    private ProductService $service
) {}

// ❌ JANGAN
private ProductService $service = new ProductService;  // JANGAN
public ProductService $service;  // JANGAN (public)

Service

ZONK — Semua Logic Ada di Sini

// ✅ Service adalah tempat SELURUH logic
// Komunikasi DB, validasi bisnis, manipulasi data — semua di sini

class ProductService
{
    public function store(array $data): Product
    {
        $data['slug'] = Str::slug($data['name']);
        return Product::create($data);
    }

    public function update(Product $product, array $data): Product
    {
        $product->update($data);
        return $product;
    }

    public function destroy(Product $product): void
    {
        $product->delete();
    }

    public function resetPassword(User $user, array $data): void
    {
        $user->update([
            'password' => Hash::make($data['password']),
        ]);
    }
}

Penamaan Method — HARUS KONSISTEN

// ✅ Nama method harus KONSISTEN di SEMUA service
// Semua service HARUS punya method ini:

// 1. paginated() — untuk paginasi
public function paginated(
    int $perPage = 25,
    string $search = '',
    string $sort = 'created_at',
    string $direction = 'desc',
    array $filters = []
): LengthAwarePaginator {
    return Product::query()
        ->select(['id', 'name', 'slug', 'status'])  // ✅ select kolom yang dibutuhkan
        ->with(['categories:id,name'])                 // ✅ eager load relasi
        ->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%"))
        ->orderBy($sort, $direction)
        ->paginate($perPage);
}

// 2. getAll() — untuk semua data (tanpa paginasi)
public function getAll(): Collection
{
    return Product::query()
        ->select(['id', 'name'])
        ->get();
}

// 3. store() — untuk create
public function store(array $data): Product
{
    return Product::create($data);
}

// 4. update() — untuk update
public function update(Product $product, array $data): Product
{
    $product->update($data);
    return $product;
}

// 5. destroy() — untuk delete
public function destroy(Product $product): void
{
    $product->delete();
}

// ❌ JANGAN beda-beda nama untuk fungsi yang sama
// ❌ getProducts(), fetchProducts(), listProducts()
// ✅ paginated() — 1 nama untuk 1 fungsi

Return Type — Wajib di Semua Method

// ✅ Return type wajib ada di SEMUA method
public function paginated(...): LengthAwarePaginator { ... }
public function getAll(...): Collection { ... }
public function store(array $data): Product { ... }
public function update(Product $product, array $data): Product { ... }
public function destroy(Product $product): void { ... }

Property Declaration

// ✅ Di service, property declaration seperti biasa (private readonly)
class ProductService
{
    private readonly CuttingService $cuttingService;

    public function __construct(CuttingService $cuttingService)
    {
        $this->cuttingService = $cuttingService;
    }
}

Urutan Method — KONSISTEN

// ✅ Urutan method di service WAJIB konsisten:
// 1. CRUD (paginated, getAll, store, update, destroy)
// 2. Custom methods (business logic)
// 3. Private helpers (di paling bawah)

class ProductService
{
    // 1. CRUD
    public function paginated(...): LengthAwarePaginator { ... }
    public function getAll(...): Collection { ... }
    public function store(array $data): Product { ... }
    public function update(Product $product, array $data): Product { ... }
    public function destroy(Product $product): bool { ... }

    // 2. Custom methods
    public function getNames(): array { ... }
    public function getForEdit(Product $product): array { ... }
    public function toggleStatus(Product $product): void { ... }
    public function approve(Product $product): void { ... }
    public function reject(Product $product, string $reason): void { ... }
    public function resubmit(Product $product): void { ... }

    // 3. Private helpers (paling bawah)
    private function canVerify(): bool { ... }
    private function assertNotPending(Product $product): void { ... }
}

Service Concerns (Traits)

// ✅ Gunakan traits untuk kode yang berulang di banyak service

// 1. HandlesCashTransactions — untuk operasi kas
use App\Services\Concerns\HandlesCashTransactions;

class ExpenseService
{
    use HandlesCashTransactions;

    public function store(array $data): Expense
    {
        $cashAccount = $this->getCashAccount($data['cash_account_id']);
        $this->debitCash($cashAccount, $data['amount'], $data['description']);
        return Expense::create($data);
    }
}

// 2. HasStockAdjustment — untuk operasi stok
use App\Services\Concerns\HasStockAdjustment;

class TransactionService
{
    use HasStockAdjustment;

    public function store(array $data): Order
    {
        $this->applyStock($data['items'], $data['stock_type'], -1); // kurangi stok
        return Order::create($data);
    }
}

// 3. RegistersMedia — untuk upload file
use App\Services\Concerns\RegistersMedia;

class RestockService
{
    use RegistersMedia;

    public function store(array $data): Restock
    {
        $restock = Restock::create($data);
        $this->registerMedia($restock, $data['photo'] ?? null, 'restock');
        return $restock;
    }
}

// 4. HasRoleChecks — untuk role checking
use App\Concerns\HasRoleChecks;

class ProductService
{
    use HasRoleChecks;

    public function store(array $data): Product
    {
        $status = self::hasAnyRole([Role::DEVELOPER, Role::OWNER])
            ? ProductStatus::ACTIVE
            : ProductStatus::PENDING;
        // ...
    }
}

Trait Stacking — Gunakan Koma

// ✅ Gabungkan multiple traits dengan koma dalam 1 baris
class PayrollPeriodService
{
    use HandlesCashTransactions, HasRoleChecks;
}

class AttendanceService
{
    use RegistersMedia, HasRoleChecks;
}

// ❌ JANGAN pisah per baris
class PayrollPeriodService
{
    use HandlesCashTransactions;
    use HasRoleChecks;  // JANGAN
}

Role Checking — Gunakan RoleEnum

// ✅ SELALU gunakan Role enum, JANGAN string
use App\Concerns\HasRoleChecks;
use App\Enums\Role;

class ProductService
{
    use HasRoleChecks;

    public function store(array $data): Product
    {
        // ✅ BENAR — pakai Role enum
        if (self::hasAnyRole([Role::DEVELOPER, Role::OWNER])) { ... }

        // ❌ SALAH — pakai string
        if (auth()->user()->hasAnyRole(['developer', 'owner'])) { ... }
    }
}

Cash Transaction Pattern

// Credit (DEPOSIT) → tambah saldo
$this->creditCash($cashAccount, $amount, $description);

// Debit (EXPENSE/WITHDRAWAL) → kurangi saldo + validasi
$this->debitCash($cashAccount, $amount, $description);

Stock Adjustment Pattern

// ProductVariant — increment/decrement stock
$this->applyStock($items, $stockType, $sign); // $sign: 1 (tambah) atau -1 (kurangi)

// RawMaterialPrice — increment/decrement
$this->adjustStock($model, $field, $quantity, $sign);

NotificationService — Gunakan RoleEnum

// ✅ SELALU gunakan Role enum untuk roles parameter
use App\Enums\Role;
use App\Services\NotificationService;

NotificationService::notify(
    roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
    title: 'Judul Notifikasi',
    body: 'Isi notifikasi',
    url: route('admin.module.index'),
);

// ❌ JANGAN pakai string
NotificationService::notify(
    roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],  // JANGAN
    ...
);

Media Sync — Gunakan syncPhoto/syncReceipt

// ✅ SELALU gunakan syncPhoto untuk photo_key, syncReceipt untuk receipt_key
// Trait: App\Services\Concerns\RegistersMedia

// Photo — otomatis handle comparison + clear + register
$this->syncPhoto($model, $data, 'photos');

// Receipt — otomatis handle comparison + cache clearing + clear + register
$this->syncReceipt($model, $data['receipt_key'] ?? null, 'receipts', 'cache_prefix', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);

// ❌ JANGAN handle manual
if (! empty($data['photo_key'])) {
    $model->clearMediaCollection('photos');
    $this->registerMedia($model, $data['photo_key'], 'photos');  // JANGAN
}

Form Request

Validasi harus SESUAI dengan DB Schema

// ✅ SELALU samakan max length dengan DB column
// DB: varchar(50) → max:50
'name' => ['required', 'string', 'max:50'],

// DB: varchar(200) → max:200
'name' => ['required', 'string', 'max:200'],

// DB: varchar(100) → max:100
'description' => ['required', 'string', 'max:100'],

// DB: text → tidak perlu max (atau max sesuai UI)
'address' => ['nullable', 'string'],

// DB: decimal(10,7) → numeric + min/max range
'latitude' => ['required', 'numeric', 'min:-90', 'max:90'],
'longitude' => ['required', 'numeric', 'min:-180', 'max:180'],

// DB: uint → integer, min:0
'base_salary' => ['required', 'integer', 'min:0'],

// DB: ubig → integer, min:1 (untuk amount)
'amount' => ['required', 'integer', 'min:1'],

// DB: enum → Rule::in(Enum::values()) atau Rule::in(['val1', 'val2'])
'status' => ['required', Rule::in(OrderStatus::values())],
'gender' => ['nullable', 'in:male,female'],

// DB: date → 'date'
'join_date' => ['required', 'date'],

// DB: datetime → 'date' (Laravel handle)
'paid_at' => ['nullable', 'date'],

Struktur Dasar

class ProductRequest extends FormRequest
{
    use CurrencyStripping;

    public function authorize(): bool
    {
        return true; // authorization di controller/middleware
    }

    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:200'],
            'price' => ['required', 'integer'],
        ];
    }

    public function attributes(): array
    {
        return [
            'name' => 'Nama Produk',
            'price' => 'Harga',
        ];
    }

    public function prepareForValidation(): void
    {
        $this->merge($this->stripCurrencyDot($this->validated(), 'price', 'discount'));
    }
}

Rule Classes — Gunakan Illuminate\Validation\Rule

use Illuminate\Validation\Rule;

// ✅ SELALU gunakan Rule classes, JANGAN string-based rules

// exists → Rule::exists('table', 'column')
'category_ids.*' => [Rule::exists('categories', 'id')],
'customer_id' => ['nullable', 'integer', Rule::exists('customers', 'id')],
'existing_items.*.raw_material_price_id' => ['required', 'integer', Rule::exists('raw_material_prices', 'id')],

// required_if → Rule::requiredIf(fn () => ...)
'shared_prices' => [Rule::requiredIf(fn () => $useSamePrice), 'nullable', 'array'],
'existing_items' => [Rule::requiredIf(fn () => $this->input('mode') === 'existing'), 'array', 'min:1'],

// required_unless → Rule::requiredUnless(fn () => ...)
'name' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'string', 'max:200'],
'unit' => [$this->isMethod('post') ? Rule::requiredUnless(fn () => $this->input('mode') === 'existing') : 'nullable', Rule::in(RawMaterialUnit::values())],

// in → Rule::in(Enum::values()) atau Rule::in(['val1', 'val2'])
'status' => ['required', Rule::in(OrderStatus::values())],
'gender' => ['nullable', 'in:male,female'],

// unique → Rule::unique('table')->ignore($id)
'name' => ['required', 'string', 'max:100', Rule::unique('categories')->ignore($this->route('category')?->id)],

// ❌ JANGAN pakai string-based rules
'category_ids.*' => ['exists:categories,id'],                    // JANGAN
'shared_prices' => ['required_if:use_same_price,true', ...],    // JANGAN
'name' => ['required_unless:mode,existing', ...],               // JANGAN

Currency Stripping

use App\Concerns\CurrencyStripping;

// ✅ Strip dot dari input Rupiah sebelum validasi
// Input: "1.000.000" → Output: 1000000
$this->merge($this->stripCurrencyDot($this->validated(), 'price', 'discount'));

// ✅ Untuk nested array
$this->merge($this->stripCurrencyDot($this->validated(), 'variants.*.price'));

Unique Ignore

// ✅ Saat update, ignore ID sendiri — gunakan Rule::unique()
'name' => ['required', 'string', 'max:100', Rule::unique('categories')->ignore($this->route('category')?->id)],

// ✅ Saat store, tidak perlu ignore
'name' => ['required', 'string', 'max:100', Rule::unique('categories')],

Shared Store/Update Request

// ✅ Gunakan 1 request dengan sometimes untuk store & update
public function rules(): array
{
    $productId = $this->route('product')?->id;

    return [
        'name' => ['required', 'string', 'max:200', Rule::unique('products')->ignore($productId)],
        'price' => ['required', 'integer'],
        'description' => ['nullable', 'string'],
    ];
}

Traits & Concerns — Reuse Kode yang Berulang

// ✅ SELALU cari kode yang berulang, jadikan Trait/Concern

// Contoh: currency formatting berulang di banyak model
// ✅ Buat Trait
namespace App\Concerns;

trait CurrencyFormatting
{
    protected function formatCurrency($value): string
    {
        return 'Rp ' . number_format($value, 0, ',', '.');
    }
}

// ✅ Gunakan di model
use App\Concerns\CurrencyFormatting;

#[Guarded(['id'])]
#[Appends(['formatted_amount'])]
class Order extends Model
{
    use HasFactory, SoftDeletes, CurrencyFormatting;

    protected function formattedAmount(): Attribute
    {
        return Attribute::make(
            get: fn () => $this->formatCurrency($this->amount),
        );
    }
}

// ✅ Contoh Traits yang sudah ada:
// - CurrencyStripping → untuk FormRequest
// - HandlesCashTransactions → untuk Service
// - HasStockAdjustment → untuk Service
// - RegistersMedia → untuk Service
// - HasValues → untuk Enum

// ✅ Jika menemukan pola berulang, buat Trait baru:
// - SoftDeletesScope (untuk scope yang sama di banyak model)
// - HasFormattedDates (untuk accessor tanggal)
// - HasFormattedCurrency (untuk accessor currency)

Frontend (React + TypeScript + Inertia)

Page Patterns

A. Simple CRUD Index (DataTable)

Digunakan untuk: Category, Customer, Supplier, Employee, Leave Request, Expense, Cash Account

import { Head } from '@inertiajs/react';
import { useState } from 'react';
import { useCan } from '@/hooks/use-can';
import { useServerTable } from '@/hooks/use-server-table';
import { PageHeader } from '@/components/page-header';
import { DataTable } from '@/components/data-table';
import { FormDialog } from '@/components/form-dialog';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { Button } from '@/components/ui/button';
import { createCategoryColumns, type Category } from './columns';

// Props dari controller (Inertia render)
type Props = {
    categories: { data: Category[]; current_page: number; last_page: number; per_page: number; total: number };
};

export default function CategoryIndex({ categories }: Props) {
    const { can } = useCan();
    const [createOpen, setCreateOpen] = useState<boolean>(false);
    const [editing, setEditing] = useState<Category | null>(null);
    const [deleting, setDeleting] = useState<Category | null>(null);

    const { search, handlePageChange, handlePerPageChange, handleSearchChange } = useServerTable({
        route: () => route('admin.master.categories.index'),
        pagination: categories,
    });

    const columns = createCategoryColumns({
        handleEdit: (cat) => setEditing(cat),
        handleDeleteClick: (cat) => setDeleting(cat),
        can,
    });

    return (
        <>
            <Head title="Kategori" />
            <div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
                <PageHeader title="Kategori" actions={can('categories.create') ? <Button onClick={() => setCreateOpen(true)}>Tambah</Button> : undefined} />

                {/* Create Dialog */}
                <FormDialog open={createOpen} onOpenChange={setCreateOpen} title="Tambah Kategori" action={route('admin.master.categories.store')} resetOnSuccess onSuccess={() => setCreateOpen(false)}>
                    {({ errors }) => (
                        <div className="grid gap-2">
                            <Label>Nama</Label>
                            <Input name="name" placeholder="Nama kategori" />
                            <InputError message={errors.name} />
                        </div>
                    )}
                </FormDialog>

                {/* Edit Dialog */}
                <FormDialog open={editing !== null} onOpenChange={(open) => !open && setEditing(null)} title="Edit Kategori" action={editing ? route('admin.master.categories.update', editing.id) : ''} resetOnSuccess onSuccess={() => setEditing(null)}>
                    {({ errors }) => editing && (
                        <div className="grid gap-2">
                            <Label>Nama</Label>
                            <Input name="name" defaultValue={editing.name} />
                            <InputError message={errors.name} />
                        </div>
                    )}
                </FormDialog>

                {/* DataTable */}
                <DataTable columns={columns} data={categories.data} searchKey="name" pagination={categories} onPageChange={handlePageChange} onPerPageChange={handlePerPageChange} onSearchChange={handleSearchChange} searchValue={search} />

                {/* Delete Confirmation */}
                <DeleteConfirmDialog target={deleting} onOpenChange={(open) => !open && setDeleting(null)} title="Hapus Kategori" description={(cat) => `Hapus kategori "${cat.name}"?`} onConfirm={() => { if (deleting) router.delete(route('admin.master.categories.destroy', deleting.id), { onSuccess: () => setDeleting(null) }); }} />
            </div>
        </>
    );
}

B. Complex List Index (CardTable)

Digunakan untuk: Transaction, Cutting, Purchase, Restock

import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
import { useServerTable } from '@/hooks/use-server-table';
import { CardTable } from '@/components/card-table';
import { FilterPopover } from '@/components/filter-popover';

type Props = {
    transactions: { data: Transaction[]; current_page: number; last_page: number; per_page: number; total: number };
    summary: TransactionSummary;
    filters: Record<string, string | undefined>;
    filterOptions: {
        statusOptions: Array<{ value: string; label: string }>;
        channelOptions: Array<{ value: string; label: string }>;
    };
};

export default function TransactionIndex({ transactions, summary, filters, filterOptions }: Props) {
    const expand = useCardTableExpand(true);
    const { search, handlePageChange, handlePerPageChange, handleSearchChange, applyFilter, clearFilters } = useServerTable({
        route: () => route('admin.manage.transactions.index'),
        pagination: transactions,
        filters,
    });

    return (
        <>
            <Head title="Transaksi" />
            <div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
                <PageHeader title="Transaksi" actions={...} />

                {/* Summary Card */}
                <TransactionSummaryCard summary={summary} />

                {/* CardTable with Filters */}
                <CardTable
                    data={transactions.data}
                    getItemKey={(t) => t.id}
                    expandedKeys={expand.expandedKeys}
                    onToggleExpand={expand.toggleExpand}
                    searchValue={search}
                    onSearchChange={handleSearchChange}
                    toolbar={<FilterPopover filters={filters} filterOptions={filterOptions} onApply={applyFilter} onClear={clearFilters} />}
                    pagination={transactions}
                    onPageChange={handlePageChange}
                    onPerPageChange={handlePerPageChange}
                    renderCard={({ item, isExpanded, onToggleExpand }) => <TransactionCardRow transaction={item} isExpanded={isExpanded} onToggleExpand={onToggleExpand} />}
                    renderSubContent={(t) => <TransactionItemSubRow transaction={t} />}
                />
            </div>
        </>
    );
}

C. Create/Edit Page (Full-page Form)

Digunakan untuk: Transaction create/edit, Cutting create/edit, Purchase create/edit

'use no memo'; // untuk complex forms

import { Head, Link, router } from '@inertiajs/react';
import { Form } from '@inertiajs/forms';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { loadTransactionDraft, useTransactionDraftSave } from '@/hooks/use-transaction-draft';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { RupiahInput } from '@/components/rupiah-input';
import { NumberInput } from '@/components/number-input';
import { FileUpload } from '@/components/file-upload';
import { Combobox } from '@/components/ui/combobox';

type Props = { data: CreateData; auth: { user: User } };

export default function TransactionCreate({ data, auth }: Props) {
    const draft = loadTransactionDraft('create', auth.user.id);
    const [customer, setCustomer] = useState(draft?.customer_id ?? null);
    const [items, setItems] = useState(draft?.items ?? []);
    const [subtotal, setSubtotal] = useState(0);

    useTransactionDraftSave('create', { customer_id: items, ... }, auth.user.id);

    // ... useEffect, useCallback untuk computed values

    return (
        <>
            <Head title="Tambah Transaksi" />
            <div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
                <div className="flex items-center justify-between">
                    <h2 className="text-2xl font-semibold tracking-tight">Tambah Transaksi</h2>
                    <Button asChild variant="outline">
                        <Link href={route('admin.manage.transactions.index')}>
                            <ArrowLeft className="h-4 w-4" /> Kembali
                        </Link>
                    </Button>
                </div>

                <Form action={route('admin.manage.transactions.store')} transform={(formData) => ({ ...formData, customer_id: customer, items, subtotal })}>
                    {({ errors, processing }) => (
                        <div className="grid gap-6 md:grid-cols-3">
                            <div className="space-y-6 md:col-span-2">
                                {/* Main content */}
                            </div>
                            <div className="space-y-6 md:col-span-1">
                                <Card className="sticky top-6">
                                    <CardHeader><CardTitle>Ringkasan</CardTitle></CardHeader>
                                    <CardContent>{/* Summary */}</CardContent>
                                </Card>
                            </div>
                        </div>
                    )}
                </Form>
            </div>
        </>
    );
}

Column Definitions

File: columns.tsx — colocated bersama page

// ✅ Type definitions — export di columns.tsx
export type Category = {
    id: number;
    name: string;
    formatted_name: string;  // accessor dari model
    status_label: string;    // label dari enum
};

// ✅ Column factory function
type CreateColumnsParams = {
    handleEdit: (entity: Category) => void;
    handleDeleteClick: (entity: Category) => void;
    can: (permission: string) => boolean;
};

export function createCategoryColumns(params: CreateColumnsParams): ColumnDef<Category>[] {
    const { handleEdit, handleDeleteClick, can } = params;

    const columns: ColumnDef<Category>[] = [
        {
            accessorKey: 'name',
            header: () => <span>Nama</span>,
            cell: ({ row }) => <span className="font-medium">{row.getValue('name')}</span>,
        },
        // ✅ Gunakan accessor dari model, BUKAN format di TypeScript
        {
            accessorKey: 'formatted_name',
            header: () => <span>Nama</span>,
            cell: ({ row }) => <span>{row.getValue('formatted_name')}</span>,
        },
        // ✅ Label dari enum, BUKAN format di TypeScript
        {
            accessorKey: 'status_label',
            header: () => <span>Status</span>,
            cell: ({ row }) => <Badge>{row.getValue('status_label')}</Badge>,
        },
    ];

    // ✅ Actions column — conditional on permissions
    if (can('categories.update') || can('categories.delete')) {
        columns.push({
            id: 'actions',
            header: () => <span className="block text-center">Aksi</span>,
            meta: { className: 'w-[100px] text-center' },
            cell: ({ row }) => (
                <RowActions actions={[
                    { label: 'Edit', icon: <Pencil />, show: can('categories.update'), onClick: () => handleEdit(row.original) },
                    { label: 'Hapus', icon: <Trash2 className="text-destructive" />, show: can('categories.delete'), onClick: () => handleDeleteClick(row.original) },
                ]} />
            ),
        });
    }

    return columns;
}

Form Handling

FormDialog (Simple CRUD — Inline)

import { FormDialog } from '@/components/form-dialog';

// ✅ Create
<FormDialog
    open={createOpen}
    onOpenChange={setCreateOpen}
    title="Tambah Category"
    action={route('admin.master.categories.store')}  // Inertia route
    resetOnSuccess                                    // Reset form setelah success
    onSuccess={() => setCreateOpen(false)}            // Tutup dialog setelah success
>
    {({ errors, processing }) => (
        <div className="grid gap-2">
            <Label>Nama <span className="text-destructive">*</span></Label>
            <Input name="name" placeholder="Nama kategori" />
            <InputError message={errors.name} />
        </div>
    )}
</FormDialog>

// ✅ Edit — gunakan defaultValue
<FormDialog
    open={editing !== null}
    onOpenChange={(open) => !open && setEditing(null)}
    title="Edit Category"
    action={editing ? route('admin.master.categories.update', editing.id) : ''}
    resetOnSuccess
    onSuccess={() => setEditing(null)}
>
    {({ errors }) => editing && (
        <div className="grid gap-2">
            <Label>Nama</Label>
            <Input name="name" defaultValue={editing.name} />
            <InputError message={errors.name} />
        </div>
    )}
</FormDialog>

Full-page Form (Complex Create/Edit)

import { Form } from '@inertiajs/forms';
import { RupiahInput } from '@/components/rupiah-input';
import { NumberInput } from '@/components/number-input';
import { FileUpload } from '@/components/file-upload';
import { Combobox } from '@/components/ui/combobox';

<Form action={route('admin.manage.transactions.store')} transform={(formData) => ({ ...formData, items })}>
    {({ errors, processing }) => (
        <div>
            {/* Controlled fields — gunakan useState */}
            <RupiahInput value={amount} onValueChange={setAmount} />
            <NumberInput value={qty} onValueChange={setQty} />
            <FileUpload value={photo} onChange={setPhoto} folder="transaction" />

            {/* Uncontrolled fields — gunakan name */}
            <Input name="description" defaultValue={editing?.description} />

            {/* Combobox untuk dropdown */}
            <Combobox items={customers} value={customer} onValueChange={setCustomer}>
                <ComboboxInput placeholder="Pilih customer" />
                <ComboboxContent>
                    <ComboboxList>{(item) => <ComboboxItem value={item}>{item.name}</ComboboxItem>}</ComboboxList>
                </ComboboxContent>
            </Combobox>

            <Button type="submit" disabled={processing}>Simpan</Button>
        </div>
    )}
</Form>

Hooks

import { useCan } from '@/hooks/use-can';
import { useServerTable } from '@/hooks/use-server-table';
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';

// ✅ useCan — permission checking
const { can, canAny, hasRole, hasRoleAny } = useCan();
can('categories.create')       // boolean
hasRole('developer')           // boolean

// ✅ useServerTable — pagination, search, filter
const { search, filterOpen, setFilterOpen, handlePageChange, handlePerPageChange, handleSearchChange, applyFilter, clearFilters } = useServerTable({
    route: () => route('admin.manage.transactions.index'),
    pagination: transactions,
    filters: filters,
});

// ✅ useCardTableExpand — expand/collapse rows
const expand = useCardTableExpand(true);  // true = expand all by default
// Returns: { expandedKeys, toggleExpand, expandAll, collapseAll }

// ✅ Draft System — auto-save form draft ke localStorage
import { useTransactionDraftSave, loadTransactionDraft } from '@/hooks/use-transaction-draft';
const draft = loadTransactionDraft('create', userId);
const [field, setField] = useState(draft?.field ?? defaultValue);
useTransactionDraftSave('create', draftData, userId);
// Auto-save 500ms debounce, cleared on success

Components

Component Digunakan Untuk
PageHeader Header halaman: title + description + actions
DataTable Table server-side pagination + search (simple CRUD)
CardTable Card-based list + expandable rows (complex entities)
FormDialog Dialog + Inertia Form wrapper (inline create/edit)
DeleteConfirmDialog Konfirmasi hapus (target-based open state)
RowActions Action dropdown (edit/hapus/custom)
FilterPopover Filter toolbar dengan badge jumlah active filters
RupiahInput Input currency "Rp X.XXX" (controlled)
NumberInput Input angka dengan locale formatting
FileUpload Upload file ke S3 dengan preview
PhoneNumberInput Input nomor telepon
StatusBadge Badge status dengan color mapping

Permission System

// Backend: kirim permission ke view via Spatie
// Frontend: check dengan useCan()

const { can, canAny, hasRole, hasRoleAny } = useCan();

// Permission string: {entity}.{action}
can('categories.create')    // boolean
can('categories.update')    // boolean
can('categories.delete')    // boolean
can('orders.create')        // boolean

// Roles bypass all permissions:
// 'developer' dan 'owner' selalu akses semua

Data Flow (Controller → Frontend)

// Controller: kirim data via Inertia::render()
return Inertia::render('Admin/Master/Products/Index', [
    'products' => $this->service->paginated(...),  // paginated data
    'filterOptions' => [                            // filter options dari enum
        'statusOptions' => OrderStatus::toSelect(),
        'channelOptions' => OrderChannel::toSelect(),
    ],
]);
// Frontend: terima sebagai page props
type Props = {
    products: { data: Product[]; current_page: number; last_page: number; per_page: number; total: number };
    filterOptions: { statusOptions: Array<{ value: string; label: string }> };
};

Routing (Ziggy)

import { index, store, update, destroy } from '@/routes/admin/master/categories';

// Ziggy route helpers
index.url()                    // GET /admin/master/categories
store()                        // POST /admin/master/categories
update(editing.id)             // PUT /admin/master/categories/{id}
destroy(deleting.id)           // DELETE /admin/master/categories/{id}

// With parameters
route('admin.manage.transactions.show', transaction.id)  // URL
route('admin.manage.transactions.update', transaction.id)

Model Accessors → Gunakan formatted_ dari Backend

// ✅ Gunakan accessor dari model
{ row.original.formatted_amount }
{ row.original.status_label }

// ❌ Jangan format di TypeScript
import { formatCurrency } from '@/lib/utils';
{ formatCurrency(row.original.amount) }  // JANGAN

Enum Options → Dari Controller

// Controller: kirim ke view
'filterOptions' => [
    'statusOptions' => OrderStatus::toSelect(),
],
// View: gunakan options
{filterOptions.statusOptions.map((opt) => (
    <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}

Format Functions

import { formatDate, formatShortDate, formatDateTime } from '@/lib/format';
import { formatCurrency, formatNumber } from '@/lib/utils';

formatDate(dateString)      // "23 Agustus 2026 08:40"
formatShortDate(dateString) // "23 Agst 2026"
formatDateTime(dateString)  // "23 Agustus 2026 08:40"
formatCurrency(1000000)     // "Rp 1.000.000"
formatNumber(1000000)       // "1.000.000"

Database

Foreign Key Convention

// BelongsTo → foreign key = model_name_id
$table->foreignId('user_id')->constrained()->cascadeOnDelete();

// Custom FK
$table->foreignId('created_by_id')->constrained('users')->restrictOnDelete();

// Polymorphic
$table->nullableMorphs('reference');

SoftDeletes

// Hampir semua model PAKAI SoftDeletes
$table->softDeletes();

// Kecuali: Attendance, StokOpnameItem, RetailStockHistory, StockMutation

Timestamps

// Default: created_at + updated_at
$table->timestamps();

// RetailStockHistory: hanya created_at (set $timestamps = false di model)

Do's and Don'ts

Do

  • Selalu pakai #[Guarded(['id'])] di model
  • Selalu cast kolom yang perlu (Enum, integer, boolean, array, datetime)
  • Selalu buat Enum untuk data opsi (status, type, dll)
  • Selalu buat scope untuk data opsi, gunakan atribut #[Scope] + return type void
  • Selalu tulis relasi 2 ARAH di kedua model
  • Selalu pakai formatted_* accessor untuk display (NAMA BERBEDA dari kolom DB)
  • Tidak perlu accessor untuk label — Enum sudah handle via ->label()
  • Selalu select kolom yang dibutuhkan + eager load relasi
  • Selalu strip currency di FormRequest sebelum validasi
  • Selalu pakai Rule::exists(), Rule::requiredIf(), Rule::requiredUnless() (bukan string-based rules)
  • Selalu pakai constructor promotion di controller (public function __construct(private XService $service) {})
  • Selalu kirim enum options dari controller ke view (Enum::toSelect())
  • Selalu pakai handleAction() untuk 2+ query
  • Selalu pakai back() + Inertia::flash('toast', [...]) sebelum return back()
  • Selalu pindahkan logic ke Service (controller = lalu lintas saja)
  • Selalu pakai nama method yang konsisten di semua service (paginated, getAll, store, update, destroy)
  • Selalu cari kode berulang → jadikan Trait/Concern
  • Selalu pakai Role enum untuk role checking & notifikasi (bukan string)
  • Selalu pakai syncPhoto/syncReceipt untuk media handling (bukan manual)
  • Selalu pakai withTrashed() untuk relasi ke model yang mungkin di-soft-delete
  • Selalu pakai return type di SEMUA method (controller & service)
  • Frontend: Selalu gunakan formatted_* accessor dari model, bukan format di TypeScript
  • Frontend: Selalu kirim enum options dari controller, jangan hardcoded di view
  • Frontend: Selalu gunakan Ziggy route helpers, jangan hardcoded URLs
  • Frontend: Selalu gunakan useCan() untuk permission checking
  • Frontend: Selalu definisikan type di columns.tsx (colocated)
  • Frontend: Selalu gunakan FormDialog untuk simple CRUD, <Form> untuk complex

Don't

  • Jangan pakai $fillable (pakai #[Guarded])
  • Jangan biarkan kolom tanpa cast
  • Jangan pakai string biasa untuk data opsi (pakai Enum)
  • Jangan pakai string untuk role checking (pakai Role enum)
  • Jangan handle media sync manual (pakai syncPhoto/syncReceipt)
  • Jangan lupa buat scope untuk data opsi, gunakan #[Scope] + return type void
  • Jangan pakai public function untuk scope (pakai protected function)
  • Jangan lupa tulis relasi 2 ARAH
  • Jangan pakai nama accessor yang sama dengan kolom DB (amount() → bentrok)
  • Jangan hardcode enum values di view (ambil dari controller)
  • Jangan pakai = new XService (pakai dependency injection)
  • Jangan lupa strip currency saat input Rupiah
  • Jangan pakai string-based rules seperti 'exists:table,id', 'required_if:field,value', 'required_unless:field,value' (pakai Rule classes)
  • Jangan lupa authorize() di FormRequest (return true)
  • Jangan lupa attributes() di FormRequest (label Bahasa Indonesia)
  • JANGAN ada logic di controller (SEKECIL APAPUN)
  • Jangan panggil Model langsung di controller (pakai Service)
  • Jangan pakai to_route() untuk kembali ke halaman yang sama (pakai back())
  • Jangan pakai nama method yang beda-beda untuk fungsi yang sama
  • Frontend: Jangan format currency/angka di TypeScript (pakai accessor dari model)
  • Frontend: Jangan hardcode select options di view (ambil dari controller via Enum::toSelect())
  • Frontend: Jangan hardcoded URLs (pakai Ziggy route helpers)
  • Frontend: Jangan lupa permission check dengan can() di setiap action
  • Frontend: Jangan lupa return type di semua method
  • Frontend: Jangan gunakan router.post() untuk navigate, gunakan Inertia <Form>