Clean Architecture in Laravel: Practical Patterns for Real Projects
How to apply Clean Architecture principles in Laravel without over-engineering. A pragmatic approach to domain-driven design.
Laravel's default structure works well for small to medium projects. But as applications grow, mixing business logic with framework code becomes a maintenance nightmare. Clean Architecture offers a solution - when applied pragmatically.
The Problem with Default Laravel
In a typical Laravel app, you might see code like this in a controller:
class OrderController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([
'items' => 'required|array',
'shipping_address_id' => 'required|exists:addresses,id',
]);
$order = Order::create([
'user_id' => auth()->id(),
'status' => 'pending',
]);
foreach ($validated['items'] as $item) {
$product = Product::find($item['product_id']);
$order->items()->create([
'product_id' => $product->id,
'quantity' => $item['quantity'],
'price' => $product->price,
]);
$product->decrement('stock', $item['quantity']);
}
// Send notification
Mail::to($order->user)->send(new OrderConfirmation($order));
return redirect()->route('orders.show', $order);
}
}This controller knows too much:
- Database structure
- Business rules (stock management)
- Email sending
- Navigation logic
A Cleaner Approach
I organize Laravel projects into layers:
app/
โโโ Domain/ # Business logic, no framework dependencies
โ โโโ Orders/
โ โ โโโ Order.php
โ โ โโโ OrderItem.php
โ โ โโโ OrderService.php
โ โโโ Products/
โโโ Application/ # Use cases, orchestration
โ โโโ Orders/
โ โโโ CreateOrderAction.php
โโโ Infrastructure/ # Framework, database, external services
โ โโโ Persistence/
โ โโโ Mail/
โโโ Http/ # Controllers, requests, resources
โโโ Controllers/Domain Layer
The domain layer contains pure business logic. No Eloquent, no Laravel facades - just PHP classes.
// Domain/Orders/Order.php
class Order
{
private array $items = [];
private OrderStatus $status;
public function __construct(
private string $id,
private string $customerId
) {
$this->status = OrderStatus::Pending;
}
public function addItem(Product $product, int $quantity): void
{
if ($quantity <= 0) {
throw new InvalidQuantityException();
}
if ($product->stockCount() < $quantity) {
throw new InsufficientStockException($product);
}
$this->items[] = new OrderItem(
product: $product,
quantity: $quantity,
priceAtPurchase: $product->price()
);
}
public function total(): Money
{
return array_reduce(
$this->items,
fn (Money $sum, OrderItem $item) => $sum->add($item->subtotal()),
Money::zero()
);
}
}Application Layer
The application layer orchestrates domain objects and coordinates with infrastructure:
// Application/Orders/CreateOrderAction.php
class CreateOrderAction
{
public function __construct(
private OrderRepository $orders,
private ProductRepository $products,
private EventDispatcher $events
) {}
public function execute(CreateOrderDto $dto): Order
{
$order = new Order(
id: Uuid::generate(),
customerId: $dto->customerId
);
foreach ($dto->items as $itemDto) {
$product = $this->products->findOrFail($itemDto->productId);
$order->addItem($product, $itemDto->quantity);
}
$this->orders->save($order);
$this->events->dispatch(new OrderCreated($order));
return $order;
}
}Infrastructure Layer
Infrastructure implements the interfaces defined by the domain:
// Infrastructure/Persistence/EloquentOrderRepository.php
class EloquentOrderRepository implements OrderRepository
{
public function save(Order $order): void
{
$model = OrderModel::updateOrCreate(
['id' => $order->id()],
[
'customer_id' => $order->customerId(),
'status' => $order->status()->value,
'total' => $order->total()->amount(),
]
);
// Save items...
}
public function findOrFail(string $id): Order
{
$model = OrderModel::with('items.product')->findOrFail($id);
return $this->toDomain($model);
}
}The Simplified Controller
Now the controller becomes thin:
class OrderController extends Controller
{
public function store(
CreateOrderRequest $request,
CreateOrderAction $action
) {
$order = $action->execute(
CreateOrderDto::fromRequest($request)
);
return redirect()->route('orders.show', $order->id());
}
}When to Use This
This level of architecture isn't always necessary. I use it when:
For simple CRUD applications, Laravel's default structure is perfectly fine.
Practical Tips
Clean Architecture in Laravel isn't about following rules religiously. It's about organizing code so that the important business logic is easy to find, understand, and change.