Domain-Driven Design in Laravel: Actions, DTOs, and Value Objects Without Bloat
#laravel #ddd #clean-architecture #php

Domain-Driven Design in Laravel: Actions, DTOs, and Value Objects Without Bloat

4 min read Mohamed Said Mohamed Said

The Problem With "DDD" in Most Laravel Codebases

Most teams reach for a DDD vocabulary — Actions, DTOs, Value Objects — and end up with three extra abstraction layers that do nothing except shuffle arrays between classes. The goal of this article is to show you what each concept is actually for, and how to implement each one with just enough code to earn its keep.


Value Objects: Enforce Invariants, Not Just Types

A Value Object is not a typed array. It is a small, immutable object whose equality is defined by its value, not its identity, and which enforces its own invariants at construction time.

final class Money
{
    public function __construct(
        public readonly int $amountInCents,
        public readonly string $currency,
    ) {
        if ($amountInCents < 0) {
            throw new \InvalidArgumentException('Amount cannot be negative.');
        }
        if (!in_array($currency, ['USD', 'EUR', 'GBP'], true)) {
            throw new \InvalidArgumentException("Unsupported currency: {$currency}");
        }
    }

    public function add(self $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new \LogicException('Cannot add different currencies.');
        }
        return new self($this->amountInCents + $other->amountInCents, $this->currency);
    }

    public function equals(self $other): bool
    {
        return $this->amountInCents === $other->amountInCents
            && $this->currency === $other->currency;
    }
}

Pair this with a custom Eloquent cast so the persistence layer stays transparent:

class MoneyCast implements CastsAttributes
{
    public function get($model, $key, $value, $attributes): Money
    {
        return new Money((int) $attributes['amount_in_cents'], $attributes['currency']);
    }

    public function set($model, $key, $value, $attributes): array
    {
        return [
            'amount_in_cents' => $value->amountInCents,
            'currency'        => $value->currency,
        ];
    }
}

Now $order->total is always a valid Money object — no validation scattered across controllers.


DTOs: Structured Input, Not Fancy Arrays

A DTO carries validated, typed data across a boundary. In Laravel, the natural boundary is the HTTP layer → domain layer. Use PHP 8 readonly properties and a static factory that reads from a FormRequest:

final readonly class CreateOrderData
{
    public function __construct(
        public int    $customerId,
        public Money  $total,
        public string $notes,
    ) {}

    public static function fromRequest(CreateOrderRequest $request): self
    {
        return new self(
            customerId: $request->integer('customer_id'),
            total: new Money(
                $request->integer('amount_in_cents'),
                $request->string('currency')->toString(),
            ),
            notes: $request->string('notes')->trim()->toString(),
        );
    }
}

No base class, no macro magic — just a plain PHP object that is trivially constructable in tests.


Actions: One Class, One Job

An Action encapsulates a single use-case. It is not a service class with ten methods. It is not a job. It is the orchestration layer between your DTO and your domain models.

final class CreateOrder
{
    public function __construct(
        private readonly OrderRepository $orders,
        private readonly EventDispatcher $events,
    ) {}

    public function execute(CreateOrderData $data): Order
    {
        $order = Order::create([
            'customer_id' => $data->customerId,
            'total'       => $data->total,
            'notes'       => $data->notes,
        ]);

        $this->events->dispatch(new OrderCreated($order));

        return $order;
    }
}

Wire it through the service container and call it from your controller:

class OrderController extends Controller
{
    public function store(CreateOrderRequest $request, CreateOrder $action): JsonResponse
    {
        $order = $action->execute(CreateOrderData::fromRequest($request));

        return OrderResource::make($order)->response()->setStatusCode(201);
    }
}

The controller has zero business logic. The action has zero HTTP knowledge. Both are independently testable.


Testing the Stack Without a Browser

it('creates an order and dispatches OrderCreated', function () {
    Event::fake([OrderCreated::class]);

    $data = new CreateOrderData(
        customerId: 1,
        total: new Money(5000, 'USD'),
        notes: 'Rush delivery',
    );

    $order = app(CreateOrder::class)->execute($data);

    expect($order->total->amountInCents)->toBe(5000);
    Event::assertDispatched(OrderCreated::class);
});

No HTTP overhead, no form parsing — just domain logic under test.


Key Takeaways

  • Value Objects enforce invariants at construction; pair them with Eloquent casts to keep models clean.
  • DTOs are typed, immutable data carriers — static factory methods on FormRequest keep the boundary explicit.
  • Actions are single-responsibility orchestrators; inject dependencies via the constructor, not the execute method.
  • Avoid base classes and trait soup — each concept earns its keep through simplicity, not inheritance.
  • The entire stack is unit-testable without booting HTTP or a browser.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Should Actions be dispatched as jobs or called synchronously?
Actions should be synchronous orchestrators. If you need async execution, wrap the Action call inside a dedicated Job class rather than making the Action itself a job — this keeps the two concerns separate and the Action fully unit-testable.
Q02 Do I need a package like lorisleiva/laravel-actions for this pattern?
No. The pattern described here uses plain PHP classes resolved from Laravel's service container. Third-party action packages add convenience features but also coupling. Start with plain classes and reach for a package only when the ergonomics genuinely justify the dependency.
Q03 Where should Value Object validation live — in the VO constructor or in a FormRequest?
Both, for different reasons. The FormRequest validates user-facing input and returns friendly error messages. The Value Object constructor enforces domain invariants and throws exceptions — it is the last line of defence regardless of how the object was constructed.

Continue reading

More Articles

View all