Overview
ShelfWise follows a strict Service Layer Architecture where all business logic resides in dedicated service classes, keeping controllers thin and focused on request/response handling. This pattern ensures code reusability, testability, and maintainability.
Core Principle : Controllers should never contain business logic. They orchestrate services and handle HTTP concerns only.
Architecture Pattern
Controller (HTTP Layer)
↓ validates & authorizes
Service Layer (Business Logic)
↓ orchestrates
Models & Database (Data Layer)
Controller Responsibilities
Validate incoming requests via Form Requests
Authorize actions using Laravel Gates/Policies
Call service methods with validated data
Return appropriate HTTP responses
Service Responsibilities
Implement all business logic
Coordinate multiple models and operations
Handle database transactions
Manage complex calculations and workflows
Log important operations
Real-World Example: ProductService
Here’s how the ProductService handles product creation:
app/Services/ProductService.php
namespace App\Services ;
use Illuminate\Support\Facades\ DB ;
use Illuminate\Support\Facades\ Log ;
class ProductService
{
public function create ( array $data , Tenant $tenant , Shop $shop ) : Product
{
Log :: info ( 'Product creation process started.' , [
'tenant_id' => $tenant -> id ,
'shop_id' => $shop -> id ,
]);
try {
return DB :: transaction ( function () use ( $data , $tenant , $shop ) {
$productType = $this -> resolveProductType ( $data [ 'product_type_slug' ], $tenant );
$slug = $this -> generateUniqueSlug ( $data [ 'name' ], $tenant );
$productData = [
'tenant_id' => $tenant -> id ,
'shop_id' => $shop -> id ,
'product_type_id' => $productType -> id ,
'name' => $data [ 'name' ],
'slug' => $slug ,
'has_variants' => $data [ 'has_variants' ] ?? false ,
];
$product = Product :: query () -> create ( $productData );
if ( $product -> has_variants && isset ( $data [ 'variants' ])) {
foreach ( $data [ 'variants' ] as $variantData ) {
$this -> createVariant ( $product , $variantData );
}
} else {
$this -> createDefaultVariant ( $product , $data );
}
Log :: info ( 'Product created successfully.' , [ 'product_id' => $product -> id ]);
return $product -> load ( 'type' , 'category' , 'variants' );
});
} catch ( Throwable $e ) {
Log :: error ( 'Product creation failed.' , [ 'exception' => $e ]);
throw $e ;
}
}
}
Key Features Demonstrated
All complex operations that modify multiple tables are wrapped in DB::transaction() to ensure atomicity. If any step fails, all changes are rolled back. DB :: transaction ( function () {
// All database operations here
// Either all succeed or all rollback
});
Services log both successful operations and failures with contextual data for debugging: Log :: info ( 'Product creation process started.' , [ 'tenant_id' => $tenant -> id ]);
Log :: error ( 'Product creation failed.' , [ 'exception' => $e ]);
Complex logic is broken down into private methods that are tested and reused: private function generateUniqueSlug ( string $name , Tenant $tenant ) : string
private function createVariant ( Product $product , array $data ) : ProductVariant
Services return models with relationships pre-loaded to prevent N+1 queries: return $product -> load ( 'type' , 'category' , 'variants.packagingTypes' );
Service Layer Best Practices
1. Dependency Injection
Services receive dependencies through constructor injection:
app/Services/OrderService.php
class OrderService
{
public function __construct (
private readonly StockMovementService $stockMovementService
) {}
public function fulfillOrder ( Order $order , User $user ) : Order
{
// Use injected service
$this -> stockMovementService -> adjustStock (
$variant ,
$location ,
$quantity ,
StockMovementType :: SALE ,
$user ,
"Order #{ $order -> order_number }"
);
}
}
2. Type Hints and Return Types
Always use strict type hints and return types:
public function createOrder (
Tenant $tenant ,
Shop $shop ,
array $items ,
User $createdBy ,
? User $customer = null
) : Order {
// Method implementation
}
3. Exception Handling
Services catch exceptions, log them, and re-throw for the controller to handle:
try {
return DB :: transaction ( function () use ( $data ) {
// Business logic
});
} catch ( Throwable $e ) {
Log :: error ( 'Operation failed.' , [ 'exception' => $e ]);
throw $e ;
}
4. Explicit Query Builder Usage
Always use Model::query() instead of static methods like Model::where() for consistency and clarity.
// ✅ 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 );
Common Service Patterns
Orchestrating Multiple Operations
Services coordinate complex workflows across multiple models:
app/Services/OrderService.php
public function confirmOrder ( Order $order , User $user ) : Order
{
return DB :: transaction ( function () use ( $order ) {
foreach ( $order -> items as $item ) {
$variant = $item -> productVariant ;
// Use lockForUpdate to prevent race conditions
$location = $variant -> inventoryLocations ()
-> where ( 'location_type' , 'App \\ Models \\ Shop' )
-> where ( 'location_id' , $order -> shop_id )
-> lockForUpdate ()
-> first ();
// Check available stock
$availableStock = $location -> quantity - $location -> reserved_quantity ;
if ( $availableStock < $item -> quantity ) {
throw new Exception ( "Insufficient stock for variant { $variant -> sku }" );
}
// Use atomic increment to prevent race conditions
$location -> increment ( 'reserved_quantity' , $item -> quantity );
}
$order -> status = OrderStatus :: CONFIRMED ;
$order -> confirmed_at = now ();
$order -> save ();
return $order ;
});
}
Caching Strategies
Services implement caching for expensive operations:
app/Services/ProductService.php
private function resolveProductType ( string $slug , Tenant $tenant ) : ProductType
{
$cacheKey = "tenant: $tenant -> id :product_type:slug: $slug " ;
return Cache :: tags ([ "tenant: $tenant -> id :product_types" ])
-> remember ( $cacheKey , 3600 , function () use ( $slug , $tenant ) {
return ProductType :: accessibleTo ( $tenant -> id )
-> where ( 'slug' , $slug )
-> firstOrFail ();
});
}
Invalidating Cache
When data changes, services invalidate relevant cache tags:
Cache :: tags ([
"tenant: $product -> tenant_id :products:list" ,
"tenant: $product -> tenant_id :product: $product -> id " ,
]) -> flush ();
Testing Services
Services are designed to be easily testable:
tests/Feature/Services/ProductServiceTest.php
use App\Services\ ProductService ;
test ( 'creates product with variants' , function () {
$tenant = Tenant :: factory () -> create ();
$shop = Shop :: factory () -> for ( $tenant ) -> create ();
$service = app ( ProductService :: class );
$product = $service -> create ([
'name' => 'Test Product' ,
'product_type_slug' => 'simple' ,
'has_variants' => true ,
'variants' => [
[ 'sku' => 'TEST-001' , 'price' => 10.00 ],
],
], $tenant , $shop );
expect ( $product -> variants ) -> toHaveCount ( 1 );
expect ( $product -> tenant_id ) -> toBe ( $tenant -> id );
});
Service Discovery
Services are automatically resolved via Laravel’s service container:
// In controllers
public function __construct (
private readonly ProductService $productService
) {}
// Using app() helper
$service = app ( ProductService :: class );
// Using dependency injection in other services
class OrderService
{
public function __construct (
private readonly StockMovementService $stockMovementService ,
private readonly NotificationService $notificationService
) {}
}