Architecture
ShelfWise follows a service-oriented architecture with strict separation of concerns.
Service Layer Architecture
Keep controllers thin. Business logic belongs in Services.
Controllers should only:
- Validate input via Form Requests
- Authorize actions via Policies
- Delegate to Services
- Return responses
app/Http/Controllers/ProductController.php
public function store(StoreProductRequest $request)
{
Gate::authorize('create', Product::class);
$product = $this->productService->createProduct($request->validated());
return redirect()->route('products.index');
}
Services contain all business logic:
app/Services/StockMovementService.php
public function adjustStock(
ProductVariant $variant,
InventoryLocation $location,
int $quantity,
StockMovementType $type,
User $user,
?string $reason = null
): StockMovement {
return DB::transaction(function () use ($variant, $location, $quantity, $type, $user, $reason) {
$location = InventoryLocation::where('id', $location->id)
->lockForUpdate()
->firstOrFail();
$quantityBefore = $location->quantity;
if ($type->isDecrease() && $location->quantity < $quantity) {
throw new Exception('Insufficient stock');
}
$location->quantity = $type->isIncrease()
? $location->quantity + $quantity
: $location->quantity - $quantity;
$location->save();
return StockMovement::query()->create([...]);
});
}
All validation must use Laravel Form Request classes.
Form Requests handle:
- Input validation rules
- Custom validation messages
- Tenant-scoped uniqueness checks
Form Requests always return true in authorize(). Authorization logic belongs in Policies.
app/Http/Requests/CreateProductRequest.php
class CreateProductRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', \App\Models\Product::class);
}
public function rules(): array
{
$tenantId = $this->user()->tenant_id;
return [
'shop_id' => [
'required',
Rule::exists('shops', 'id')->where('tenant_id', $tenantId),
],
'name' => ['required', 'string', 'max:255'],
'sku' => [
'required',
'string',
Rule::unique('product_variants', 'sku')
->where(fn ($query) => $query->whereExists(
fn ($q) => $q->select(\DB::raw(1))
->from('products')
->whereColumn('products.id', 'product_variants.product_id')
->where('products.tenant_id', $tenantId)
)),
],
];
}
}
Enums
Define all enums as PHP Enums in app/Enums/. Never use database-level enums.
app/Enums/StockMovementType.php
enum StockMovementType: string
{
case PURCHASE = 'purchase';
case SALE = 'sale';
case ADJUSTMENT_IN = 'adjustment_in';
case ADJUSTMENT_OUT = 'adjustment_out';
public function isIncrease(): bool
{
return in_array($this, [self::PURCHASE, self::ADJUSTMENT_IN]);
}
}
UUID Primary Keys
All models use UUID primary keys via the HasUuid trait.
use Illuminate\Database\Eloquent\Concerns\HasUuid;
class Product extends Model
{
use HasUuid;
}
Multi-Tenancy (CRITICAL)
Tenant Isolation
Every database query MUST filter by tenant_id:
Product::query()->where('tenant_id', auth()->user()->tenant_id)->get();
app(ProductService::class)->getAllProducts();
Never query models without tenant isolation. This is a critical security requirement.
Authorization Hierarchy
8-level role system:
Super Admin (999) > Owner (100) > General Manager (80) > Store Manager (60)
> Assistant Manager (50) > Sales Rep (40) > Inventory Clerk (30) > Cashier (30)
Database Queries
Explicit Query Builder
Always use Model::query()-> instead of Model::queryMethod():
// ✅ Correct
Product::query()->where('tenant_id', $tenantId)->get();
Order::query()->find($id);
Shop::query()->create($data);
// ❌ Incorrect
Product::where('tenant_id', $tenantId)->get();
Order::find($id);
Shop::create($data);
Stock Management
Always use StockMovementService for audit trail:
app(StockMovementService::class)->adjustStock($variant, $location, $qty, $type, $user, $reason);
Direct manipulation of stock quantities bypasses the audit trail. Always use the service.
Frontend (Inertia + React)
Use the Inertia <Form> component with Wayfinder controllers.
import { Form } from '@inertiajs/react';
<Form action={route('products.store')} method="post">
<input name="name" />
<button type="submit">Create Product</button>
</Form>
For complex forms the <Form> component can’t handle, use the useForm hook.
Routing
Actions and routes come from Wayfinder. Do not hardcode routes.
// ✅ Correct
route('products.store')
// ❌ Incorrect
'/products'
Single Source of Truth
Extract reusable logic to lib/ folder:
lib/formatters.ts - Currency, date, number formatting
lib/status-configs.ts - Status labels, colors, badges
lib/calculations.ts - Business calculations
lib/utils.ts - General utilities
import { formatCurrency, formatDate } from '@/lib/formatters';
formatCurrency(1000, shop.currency_symbol, shop.currency_decimals);
UI Design
Use /frontend-design skill for any new UI components or pages.
Type Definitions
Single source of truth for types - Organize types by domain in resources/js/types/:
types/shop.ts - Shop, ShopType, StorefrontSettings
types/product.ts - Product, ProductVariant, ProductCategory
types/order.ts - Order, OrderItem, OrderPayment
types/payroll.ts - Payslip, PayRun, WageAdvance, Deductions
types/customer.ts - Customer, CustomerCredit
types/service.ts - Service, ServiceVariant, ServiceCategory
SVG Icons
Use the named ReactComponent export pattern:
import { ReactComponent as IconName } from './icon.svg?react';
Code Style
No single-line comments - Only use PHPDoc or JSDoc where absolutely necessary.
// ❌ Avoid this
// Check if user has permission
// ✅ Use PHPDoc only when needed
/**
* Check stock availability for a variant at a specific shop.
*/
public function checkStockAvailability(ProductVariant $variant, int $quantity): bool
Domain Model Reference
Tenant
├── User (staff members)
├── Customer (e-commerce buyers - SEPARATE from User)
├── Shop
│ ├── Product → ProductVariant → StockMovement
│ ├── Service → ServiceVariant
│ ├── Order → OrderItem → OrderPayment
│ └── StorefrontSettings
├── Supplier → SupplierCatalog
└── Payroll
├── PayRun → PayRunItem
├── Payslip
└── WageAdvance