Clean Architecture in Laravel: Building Maintainable Applications
Why Clean Architecture?
After building systems for 40 years, I've seen codebases that started clean become unmaintainable within a year. Clean architecture isn't about following rules—it's about making systems that remain adaptable as requirements change and teams grow.
The core principle: business logic should be independent of frameworks, databases, and external services. Your domain shouldn't know it's running in Laravel.
The Dependency Rule
Dependencies point inward. Outer layers depend on inner layers, never the reverse.
- Entities: Core business objects and rules
- Use Cases: Application-specific business rules
- Interface Adapters: Controllers, presenters, gateways
- Frameworks: Laravel, Eloquent, external services
Implementing in Laravel
Domain Layer
// app/Domain/Orders/Order.php
namespace App\Domain\Orders;
class Order
{
public function __construct(
public readonly string $id,
public readonly CustomerId $customerId,
public readonly Money $total,
public readonly OrderStatus $status,
public readonly array $items
) {}
public function canBeShipped(): bool
{
return $this->status === OrderStatus::Paid
&& $this->hasValidShippingAddress();
}
public function ship(TrackingNumber $tracking): self
{
if (!$this->canBeShipped()) {
throw new CannotShipOrderException($this);
}
return new self(
$this->id,
$this->customerId,
$this->total,
OrderStatus::Shipped,
$this->items
);
}
}
Use Cases
// app/Application/Orders/ShipOrder.php
namespace App\Application\Orders;
class ShipOrder
{
public function __construct(
private OrderRepositoryInterface $orders,
private ShippingServiceInterface $shipping,
private NotificationServiceInterface $notifications
) {}
public function execute(ShipOrderRequest $request): ShipOrderResponse
{
$order = $this->orders->findById($request->orderId);
if (!$order) {
throw new OrderNotFoundException($request->orderId);
}
$tracking = $this->shipping->createShipment($order);
$shippedOrder = $order->ship($tracking);
$this->orders->save($shippedOrder);
$this->notifications->orderShipped($shippedOrder);
return new ShipOrderResponse($shippedOrder, $tracking);
}
}
Interface Adapters
// app/Infrastructure/Repositories/EloquentOrderRepository.php
namespace App\Infrastructure\Repositories;
class EloquentOrderRepository implements OrderRepositoryInterface
{
public function findById(string $id): ?Order
{
$model = OrderModel::find($id);
if (!$model) {
return null;
}
return $this->toDomain($model);
}
public function save(Order $order): void
{
OrderModel::updateOrCreate(
['id' => $order->id],
$this->toDatabase($order)
);
}
private function toDomain(OrderModel $model): Order
{
return new Order(
$model->id,
new CustomerId($model->customer_id),
new Money($model->total, $model->currency),
OrderStatus::from($model->status),
$this->mapItems($model->items)
);
}
}
Testing Benefits
class ShipOrderTest extends TestCase
{
public function test_ships_paid_order(): void
{
$order = OrderFactory::paidOrder();
$orders = new InMemoryOrderRepository([$order]);
$shipping = new FakeShippingService();
$notifications = new FakeNotificationService();
$useCase = new ShipOrder($orders, $shipping, $notifications);
$response = $useCase->execute(new ShipOrderRequest($order->id));
$this->assertTrue($response->order->status === OrderStatus::Shipped);
$this->assertNotNull($response->tracking);
}
}
When to Apply
Clean architecture adds complexity. Use it for:
- Long-lived applications
- Complex business logic
- Multiple teams working on the same codebase
- Applications that might change frameworks or databases
Conclusion
Clean architecture keeps your business logic independent and testable. The initial investment pays off in maintainability and flexibility over time.
Related Articles
Need Help With Your Project?
I respond to all inquiries within 24 hours. Let's discuss how I can help build your production-ready system.
Get In Touch