# Code Conventions
> Aturan-aturan yang WAJIB diikuti saat menulis kode.
---
## Namespace & Import Convention
### Backend → SELALU pakai `use` import di atas file
```php
// ✅ 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
```php
// ✅ 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**:
```php
// ✅ 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**:
```tsx
// ✅ Labels
// ✅ Placeholders
// ✅ Buttons
// ✅ Flash messages
'Produk berhasil ditambahkan'
'Data berhasil dihapus'
// ✅ Status labels (di Badge)
```
### 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
```php
// ✅ SELALU pakai #[Guarded(['id'])]
#[Guarded(['id'])]
class Product extends Model { ... }
// ❌ JANGAN pakai $fillable
protected $fillable = ['name', 'status']; // JANGAN
```
### Urutan Isi Model
```php
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
```php
// ✅ 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)
'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
```php
// ✅ 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
```php
// ✅ 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
```php
// ✅ 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
```php
// ✅ 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
```php
// ✅ 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
```php
// ✅ 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
```php
// 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
```php
// ✅ 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
```php
// ✅ Return type wajib ada di SEMUA method — controller & service
// ✅ Response untuk controller, void/array/bool untuk service
```
### ZONK — Tidak Ada Logic di Controller
```php
// ✅ 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());
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil ditambahkan'])->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());
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Password berhasil direset'])->back();
}
```
### Urutan Method
```php
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()`
```php
// ✅ SELALU gunakan Inertia::flash() sebelum return back()
public function destroy(Product $product)
{
$this->service->destroy($product);
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil dihapus'])->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
```php
// ❌ 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
```php
// ✅ 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);
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil dihapus'])->back();
}
```
### Service Property — Constructor Promotion
```php
// ✅ 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
```php
// ✅ 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
```php
// ✅ 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
```php
// ✅ 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
```php
// ✅ 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
```php
// ✅ 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)
```php
// ✅ 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
```php
// ✅ 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
```php
// ✅ 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
```php
// Credit (DEPOSIT) → tambah saldo
$this->creditCash($cashAccount, $amount, $description);
// Debit (EXPENSE/WITHDRAWAL) → kurangi saldo + validasi
$this->debitCash($cashAccount, $amount, $description);
```
### Stock Adjustment Pattern
```php
// 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
```php
// ✅ 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
```php
// ✅ 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
```php
// ✅ 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
```php
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`
```php
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
```php
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
```php
// ✅ 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
```php
// ✅ 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
```php
// ✅ 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
```tsx
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] = useStateTambah Transaksi