Kevin Aubrée

Blog / · 6 min read

Clean Architecture with Laravel: stop putting all business rules into Eloquent

Laravel is fast at the start. Clean Architecture becomes useful when the domain grows beyond CRUD: actions, DTOs, ports, repositories, fast tests, progressive migration, and the limits to know.

Clean Architecture with Laravel: stop putting all business rules into Eloquent

Laravel does not force you to write messy code.

It simply lets you move very fast. Sometimes too fast. A controller receives a request, a FormRequest validates it, an Eloquent model persists it, a notification goes out, a policy checks access, and a resource transforms the response. For simple CRUD, that is exactly why Laravel is productive.

The problem starts when CRUD becomes a real business domain. Rules change, edge cases pile up, tests slow down, and the Eloquent model ends up carrying persistence, authorization, billing, pricing, state transitions, and external integrations.

Clean Architecture is not a religion. It is a way to protect the business core when Laravel is no longer just a delivery framework, but the shell around a product that must last.

The symptom

Classic Laravel MVC holds up when the application is close to the database.

You have an orders table, an OrderController, an Order Eloquent model, validation, and a JSON resource. Adding abstraction there would be more expensive than the problem.

Then the business arrives:

  • an order can only be cancelled in specific states;
  • B2B customers have different payment rules;
  • some products trigger external stock checks;
  • emails should only be sent after commit;
  • admins can force a transition but public API users cannot;
  • invoices follow legal timing rules.

If all of that lands in Order.php, the model grows. If you move it into OrderService, the service grows. If you spread it across controllers, observers, jobs, and events, nobody knows where the rule lives.

Clean Architecture asks one question: which part of the code must remain true if tomorrow you replace Eloquent, your payment provider, or the HTTP layer?

Dependency direction

The point is not creating folders because a diagram said so.

The point is dependency direction:

  • the domain does not depend on Laravel;
  • use cases do not depend on Eloquent;
  • controllers adapt HTTP to the application layer;
  • infrastructure implements ports expected by the application;
  • Laravel’s container wires everything together.

Laravel remains useful. Service container, service providers, Form Requests, policies, jobs, resources, and HTTP tests stay in the system. They just stay at the edges.

Pragmatic structure

app/
  Domain/
    Order/
      Order.php
      OrderId.php
      OrderStatus.php
      OrderLine.php
      OrderMapper.php
      CustomerId.php
      Exception/
        EmptyOrder.php
        OrderCannotBeCancelled.php
        OrderNotFound.php
  Application/
    Order/
      PlaceOrder/
        PlaceOrderAction.php
        PlaceOrderCommand.php
      Port/
        OrderRepository.php
        PaymentGateway.php
  Infrastructure/
    Order/
      Persistence/
        EloquentOrderRepository.php
        OrderModel.php
        OrderLineModel.php
  Http/
    Controllers/
      OrderController.php
    Requests/
      PlaceOrderRequest.php

HTTP and Eloquent may know the application layer. The domain should not know HTTP or Eloquent.

Domain: pure PHP

<?php

namespace App\Domain\Order;

use App\Domain\Order\Exception\EmptyOrder;
use App\Domain\Order\Exception\OrderCannotBeCancelled;

final class Order
{
    /** @param list<OrderLine> $lines */
    private function __construct(
        private readonly OrderId $id,
        private readonly CustomerId $customerId,
        private OrderStatus $status,
        private array $lines,
    ) {}

    /** @param list<OrderLine> $lines */
    public static function place(OrderId $id, CustomerId $customerId, array $lines): self
    {
        if ($lines === []) {
            throw new EmptyOrder();
        }

        return new self($id, $customerId, OrderStatus::Pending, $lines);
    }

    /** @param list<OrderLine> $lines */
    public static function reconstitute(
        OrderId $id,
        CustomerId $customerId,
        OrderStatus $status,
        array $lines,
    ): self {
        return new self($id, $customerId, $status, $lines);
    }

    public function cancel(): void
    {
        if ($this->status !== OrderStatus::Pending) {
            throw new OrderCannotBeCancelled($this->id, $this->status);
        }

        $this->status = OrderStatus::Cancelled;
    }

    public function id(): OrderId
    {
        return $this->id;
    }

    /** @return list<OrderLine> */
    public function lines(): array
    {
        return $this->lines;
    }
}

The important part is reconstitute(). A repository must be able to rebuild a cancelled order or a shipped order without going through place(), which always creates a pending order.

Application: actions orchestrate

<?php

namespace App\Application\Order\PlaceOrder;

use App\Application\Order\Port\OrderRepository;
use App\Application\Order\Port\PaymentGateway;
use App\Domain\Order\CustomerId;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderLine;

final readonly class PlaceOrderAction
{
    public function __construct(
        private OrderRepository $orders,
        private PaymentGateway $payments,
    ) {}

    public function execute(PlaceOrderCommand $command): OrderId
    {
        $lines = array_map(
            static fn (array $line): OrderLine => OrderLine::fromArray($line),
            $command->lines,
        );

        $order = Order::place(
            OrderId::new(),
            new CustomerId($command->customerId),
            $lines,
        );

        $this->orders->save($order);
        $this->payments->authorize($order->id());

        return $order->id();
    }
}

The payment call is deliberately after save. In production, prefer an idempotent outbox or afterCommit job for this kind of external effect. The snippet shows dependency direction, not a full payment workflow.

DTOs and HTTP edge

final readonly class PlaceOrderCommand
{
    /** @param list<array{sku: string, quantity: int}> $lines */
    public function __construct(
        public string $customerId,
        public array $lines,
    ) {}
}

Laravel FormRequest remains excellent at the HTTP edge.

final class PlaceOrderRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'customer_id' => ['required', 'uuid'],
            'lines' => ['required', 'array', 'min:1'],
            'lines.*.sku' => ['required', 'string'],
            'lines.*.quantity' => ['required', 'integer', 'min:1'],
        ];
    }
}
$command = new PlaceOrderCommand(
    customerId: (string) $request->string('customer_id'),
    lines: $request->array('lines'),
);

Infrastructure: Eloquent as adapter

interface OrderRepository
{
    public function get(OrderId $id): Order;

    public function save(Order $order): void;
}
final readonly class EloquentOrderRepository implements OrderRepository
{
    public function get(OrderId $id): Order
    {
        $model = OrderModel::query()->with('lines')->find($id->toString());

        if (!$model instanceof OrderModel) {
            throw new OrderNotFound($id);
        }

        return OrderMapper::toDomain($model);
    }

    public function save(Order $order): void
    {
        $model = OrderModel::query()->find($order->id()->toString()) ?? new OrderModel();

        OrderMapper::fillModel($model, $order);
        $model->save();

        OrderLineModel::syncForOrder($model, $order->lines());
    }
}

Eloquent does not automatically persist a domain collection of value objects. The line sync must happen explicitly after the parent save.

Laravel wiring

final class OrderServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(OrderRepository::class, EloquentOrderRepository::class);
        $this->app->bind(PaymentGateway::class, StripePaymentGateway::class);
    }
}

Laravel knows the concrete classes here. The domain does not.

Tests are the real benefit

public function test_shipped_order_cannot_be_cancelled(): void
{
    $order = OrderBuilder::shipped();

    $this->expectException(OrderCannotBeCancelled::class);

    $order->cancel();
}
public function test_place_order_saves_before_authorizing_payment(): void
{
    $orders = new InMemoryOrderRepository();
    $payments = new FakePaymentGateway();

    $action = new PlaceOrderAction($orders, $payments);

    $orderId = $action->execute(new PlaceOrderCommand(
        customerId: '0c69d6b0-d8b5-4d3b-91d3-c092b5fd27ef',
        lines: [['sku' => 'BOOK-1', 'quantity' => 1]],
    ));

    self::assertTrue($payments->wasAuthorizedFor($orderId));
    self::assertTrue($orders->has($orderId));
}

Keep Laravel HTTP tests for wiring, validation, auth, and serialization. Do not make them carry the whole business domain.

Migration strategy

Do not do a big bang rewrite.

  1. Pick one painful module.
  2. Write tests around current behavior.
  3. Extract one action from a controller or service.
  4. Replace arrays with DTOs.
  5. Add ports only when a dependency hurts tests or coupling.
  6. Move invariants into pure domain objects.
  7. Leave simple CRUD modules alone.

Clean Architecture should reduce risk. If it blocks delivery for three weeks, you are using it as a rewrite, not as control recovery.

Sources

Kevin Aubrée

Keep reading

Back to blog