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
readonlyis 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+
readonlyclasses make DTOs zero-dependency. - Adopt these three patterns incrementally — you don't need a full DDD stack to get the benefits.