Skip to content

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

Skip the ceremony. Learn how to apply DDD's most practical building blocks—Actions, DTOs, and Value Objects—in a Laravel codebase without drowning in abstraction layers or boilerplate.

Published · Updated · 4 min read

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

The Problem With "Just Use DDD"

Domain-Driven Design gets a bad reputation in Laravel circles because most tutorials jump straight to full hexagonal architecture, repositories, aggregates, and domain events — before you've shipped a single feature. The result is a codebase that looks impressive in a conference talk and collapses under its own weight in production.

The good news: the three most valuable DDD building blocks — Actions, DTOs, and Value Objects — can be adopted incrementally, without a framework, and without rewriting your entire application.


Value Objects: Replacing Primitives With Meaning

A Value Object wraps a primitive and enforces its own invariants. It has no identity — two Money objects with the same amount and currency are equal.

final class Money
{
    public function __construct(
        public readonly int $amountInCents,
        public readonly string $currency,
    ) {
        if ($this->amountInCents < 0) {
            throw new \InvalidArgumentException('Amount cannot be negative.');
        }
        if (!in_array($this->currency, ['USD', 'EUR', 'GBP'], true)) {
            throw new \InvalidArgumentException("Unsupported currency: {$this->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 it survives the database round-trip:

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

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

Now $order->total is always a valid Money, never a raw integer that silently accepts -999.


DTOs: Typed Input Boundaries

A DTO is a plain, immutable data carrier. Its job is to move validated data across a boundary — from a controller into a service or action — without leaking HTTP concerns.

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

    public static function fromRequest(Request $request): self
    {
        $validated = $request->validate([
            'customer_id' => ['required', 'integer', 'exists:customers,id'],
            'total_cents' => ['required', 'integer', 'min:1'],
            'currency'    => ['required', 'string', 'in:USD,EUR,GBP'],
            'line_items'  => ['required', 'array', 'min:1'],
        ]);

        return new self(
            customerId: $validated['customer_id'],
            total: new Money($validated['total_cents'], $validated['currency']),
            lineItems: $validated['line_items'],
        );
    }
}

Using PHP 8.2+ readonly classes eliminates the need for a third-party DTO library entirely.


Actions: Single-Responsibility Use Cases

An Action encapsulates one use case. It is not a service with ten methods — it is one class, one public method.

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,
            'status'      => OrderStatus::Pending,
        ]);

        foreach ($data->lineItems as $item) {
            $order->lineItems()->create($item);
        }

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

        return $order;
    }
}

Resolve it from the container in your controller:

public function store(Request $request, CreateOrder $action): JsonResponse
{
    $order = $action->execute(CreateOrderData::fromRequest($request));
    return OrderResource::make($order)->response()->setStatusCode(201);
}

The controller is now a thin HTTP adapter. The action is testable in isolation with a mocked repository.


Where People Over-Engineer

  • Repositories for every model. Use Eloquent directly until you have a real reason to swap the persistence layer.
  • Abstract base DTO classes. PHP 8.2 readonly is enough.
  • Action interfaces. Unless you're swapping implementations in tests, skip the interface.
  • Nested value objects three levels deep. If you can't explain the invariant in one sentence, it's not a value object — it's a struct.

Takeaways

  • Value Objects enforce domain invariants at construction time, eliminating defensive checks scattered across your codebase.
  • DTOs create a typed, HTTP-free boundary between your transport layer and your domain logic.
  • Actions give each use case a home, making them independently testable and easy to locate.
  • PHP 8.2+ readonly classes make DTOs zero-dependency.
  • Adopt these three patterns incrementally — you don't need a full DDD stack to get the benefits.

Frequently asked questions

Do I need a package like `lorisleiva/laravel-actions` to use the Actions pattern?

No. A plain PHP class with a single `execute` method resolved from Laravel's service container is sufficient. Third-party action packages add conveniences like running actions as jobs or commands, which is useful but not required to start.

How do Value Objects interact with Eloquent's mass assignment and `$fillable`?

Value Objects are handled at the cast layer, not mass assignment. You store the primitive representation in the database column and cast it to a Value Object on read. The `$fillable` array should reference the underlying column name, not the Value Object class.

Should DTOs replace Form Requests in Laravel?

They complement each other. Keep Form Requests for HTTP validation rules and authorization. Use a DTO to carry the validated, typed data into your domain layer. The `fromRequest` factory method on the DTO is a clean bridge between the two.

Mohamed Said

About the author

Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

About GitHub LinkedIn WhatsApp Email Address My CV

Have a technical challenge?

Tell me what you’re building. I reply within two working days.

Discuss your project