Clean Architecture with Laravel: Moving Beyond Classic MVC
Laravel is an amazing framework for starting fast. But that initial velocity has a cost: after 6 months, classic MVC projects become monoliths where business logic is scattered between Models, Controllers, and catch-all “Services”.
Clean Architecture offers an escape hatch. Not a complete rewrite — a progressive migration toward structured, testable, and maintainable code.
The limits of classic MVC with Laravel
Laravel’s MVC encourages three anti-patterns:
1. Fat Models. Eloquent pushes you to put logic in Models. Scopes, accessors, mutators, relationships, validations — everything accumulates in a class that becomes unmanageable at 500 lines.
2. Fat Controllers. When the Model isn’t enough, logic migrates to the Controller. You find validation, transformation, API calls, notifications — the controller does everything except control.
3. Catch-all Services. The first reaction is to create an OrderService with 30 public methods. It’s a God Object disguised as a service.
The result: code coupled to the framework, impossible to test without a database, and where every modification risks breaking something unexpected.
For an overview of architectural patterns and when to use them, see my guide on software architecture for modern web applications.
The layers of Clean Architecture
The Domain layer
The domain contains pure business logic. No dependency on Laravel, Eloquent, or anything external.
// src/Domain/Order/Order.php
final class Order
{
private OrderStatus $status;
private array $items = [];
public static function place(CustomerId $customerId, array $items): self
{
if (empty($items)) {
throw new EmptyOrderException();
}
// ...business logic
}
public function cancel(): void
{
if ($this->status !== OrderStatus::Pending) {
throw new OrderCannotBeCancelledException($this->status);
}
$this->status = OrderStatus::Cancelled;
}
}
Domain entities use Value Objects (CustomerId, OrderStatus), raise business exceptions, and know nothing about Illuminate\Database or Request.
The Application layer
The Application layer orchestrates use cases. This is where you find Actions — single-responsibility classes.
// src/Application/Order/PlaceOrderAction.php
final readonly class PlaceOrderAction
{
public function __construct(
private OrderRepositoryInterface $orders,
private PaymentGatewayInterface $payments,
) {}
public function execute(PlaceOrderDTO $dto): Order
{
$order = Order::place(
new CustomerId($dto->customerId),
$dto->items,
);
$this->orders->save($order);
$this->payments->authorize($order);
return $order;
}
}
The Action depends on interfaces (ports), not implementations. The Laravel container injects concrete implementations.
The Infrastructure layer
Infrastructure contains adapters: Eloquent Repositories, HTTP Controllers, API clients, etc.
// src/Infrastructure/Persistence/EloquentOrderRepository.php
final class EloquentOrderRepository implements OrderRepositoryInterface
{
public function save(Order $order): void
{
OrderModel::updateOrCreate(
['id' => $order->id()->value()],
$order->toArray(),
);
}
}
The Controller becomes ultra-light:
public function store(PlaceOrderRequest $request, PlaceOrderAction $action): JsonResponse
{
$order = $action->execute(PlaceOrderDTO::fromRequest($request));
return response()->json(OrderResource::make($order), 201);
}
Progressive migration from an existing project
You don’t rewrite everything at once. The strategy:
- Identify the most painful module. The one with the most bugs, the most logic in controllers.
- Extract DTOs. Replace associative arrays with typed objects.
- Create Actions. Move logic from controllers/models to dedicated Actions.
- Introduce interfaces. Abstract Eloquent behind Repository interfaces.
- Move the domain. Extract business rules into pure domain entities.
Each step brings immediate value. No need to wait for the migration to finish.
Unit testing with Clean Architecture
Clean Architecture makes unit tests trivial:
public function test_cannot_cancel_shipped_order(): void
{
$order = OrderFactory::shipped();
$this->expectException(OrderCannotBeCancelledException::class);
$order->cancel();
}
No RefreshDatabase, no DatabaseTransactions. The test runs in milliseconds because it only touches pure PHP.
For integration and E2E tests that complement this pyramid, see my article on E2E testing with Playwright.
Actions are tested with fakes:
public function test_place_order_authorizes_payment(): void
{
$fakePayments = new FakePaymentGateway();
$action = new PlaceOrderAction(new InMemoryOrderRepository(), $fakePayments);
$action->execute(new PlaceOrderDTO('customer-1', [['sku' => 'ITEM-1']]));
$this->assertTrue($fakePayments->wasAuthorizedFor('customer-1'));
}
Feedback
After applying Clean Architecture to 4 Laravel projects of different sizes, here’s what I observed:
The gains:
- 10x faster unit tests (no database)
- Easier onboarding (each Action is a clear entry point)
- Fearless refactoring (domain tests cover invariants)
- Framework migration possible (we did it from Laravel to Symfony on one project)
The costs:
- More files and classes (but fewer lines per file)
- Eloquent ↔ Domain mapping to maintain
- Learning curve for devs used to all-Eloquent
The verdict: if your project exceeds 6 months of life and involves non-trivial business logic, Clean Architecture pays off. For an MVP or internal tool, classic MVC remains perfectly valid.
The same reasoning applies to Symfony with CQRS and Messenger — both approaches share the same principles of isolation and testability.
Kevin De Vaubree
Senior Full-Stack Developer