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

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

4 min read Mohamed Said Mohamed Said

Why DDD Concepts Matter Without Going Full Framework

Domain-driven design gets a bad reputation for ceremony. Aggregates, repositories, domain events, bounded contexts — the vocabulary alone can paralyse a team. But the core tactical patterns — value objects, data transfer objects, and single-action classes — are lightweight enough to drop into any Laravel project today, and they pay dividends immediately in readability and testability.

This article focuses on those three patterns, how they interact, and where the common mistakes are.


Value Objects: Encapsulate Meaning, Not Just Data

A value object wraps a primitive and enforces its invariants at construction time. Once created, it is immutable.

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 model layer stays clean:

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

    public function set($model, string $key, $value, array $attributes): array
    {
        if (!$value instanceof Money) {
            throw new \InvalidArgumentException('Expected a Money instance.');
        }

        return [
            'amount_in_cents' => $value->amountInCents,
            'currency'        => $value->currency,
        ];
    }
}

Now $order->total is always a Money — never a raw integer you might accidentally display without formatting.


DTOs: Typed Input Boundaries

A DTO carries validated, typed data across a layer boundary. It is not a model, not a form request, and not a value object — it is a plain carrier.

final readonly class PlaceOrderData
{
    public function __construct(
        public int    $customerId,
        public Money  $total,
        public string $shippingAddress,
        /** @var non-empty-list<OrderLineData> */
        public array  $lines,
    ) {}

    public static function fromRequest(PlaceOrderRequest $request): self
    {
        return new self(
            customerId:      $request->integer('customer_id'),
            total:           new Money($request->integer('total_cents'), $request->string('currency')),
            shippingAddress: $request->string('shipping_address'),
            lines:           array_map(
                fn(array $line) => OrderLineData::fromArray($line),
                $request->array('lines'),
            ),
        );
    }
}

Using readonly (PHP 8.2+) eliminates the need for getters and prevents mutation after construction.


Single-Action Classes: One Class, One Job

Actions replace fat service classes. Each action does exactly one thing and declares its dependencies explicitly.

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

    public function execute(PlaceOrderData $data): Order
    {
        $order = Order::create([
            'customer_id'      => $data->customerId,
            'total_in_cents'   => $data->total->amountInCents,
            'currency'         => $data->total->currency,
            'shipping_address' => $data->shippingAddress,
        ]);

        foreach ($data->lines as $line) {
            $order->lines()->create($line->toArray());
        }

        $this->payments->charge($order);
        $this->events->dispatch(new OrderPlaced($order));

        return $order;
    }
}

In the controller:

public function store(PlaceOrderRequest $request, PlaceOrderAction $action): JsonResponse
{
    $order = $action->execute(PlaceOrderData::fromRequest($request));

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

Laravel's service container resolves PlaceOrderAction automatically. No manual wiring needed.


Testing the Stack with Pest

Because each layer is isolated, tests are small and fast:

it('adds two money values of the same currency', function () {
    $a = new Money(1000, 'USD');
    $b = new Money(500, 'USD');

    expect($a->add($b)->amountInCents)->toBe(1500);
});

it('throws when adding different currencies', function () {
    $a = new Money(1000, 'USD');
    $b = new Money(500, 'EUR');

    expect(fn() => $a->add($b))->toThrow(\LogicException::class);
});

it('places an order and dispatches an event', function () {
    Event::fake([OrderPlaced::class]);

    $action = app(PlaceOrderAction::class);
    $data   = PlaceOrderData::fromRequest(fakeOrderRequest());

    $order = $action->execute($data);

    expect($order->total_in_cents)->toBe($data->total->amountInCents);
    Event::assertDispatched(OrderPlaced::class);
});

Key Takeaways

  • Value objects enforce invariants at construction and eliminate primitive obsession.
  • DTOs create a typed boundary between HTTP input and domain logic — use readonly to prevent mutation.
  • Single-action classes keep domain logic discoverable, injectable, and independently testable.
  • Custom Eloquent casts bridge value objects and the persistence layer without leaking domain rules into models.
  • None of these patterns require a DDD framework — they are plain PHP classes resolved by Laravel's container.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Should every domain concept have a value object?
No. Reach for a value object when a primitive carries business rules — a currency amount, an email address, a percentage. Plain strings and integers are fine for identifiers and labels that have no invariants.
Q02 What is the difference between a DTO and a Form Request in Laravel?
A Form Request handles HTTP validation and authorization. A DTO is a plain PHP object that carries already-validated, typed data into the domain layer. Keeping them separate means your actions are not coupled to the HTTP layer and can be called from queued jobs or CLI commands too.
Q03 Can single-action classes replace service classes entirely?
For most use cases, yes. If you find yourself grouping three or four related actions that share significant state or helpers, a service class is still appropriate. The goal is cohesion, not dogma.

Continue reading

More Articles

View all