DDD in Laravel Without the Ceremony
Domain-driven design has a reputation for generating mountains of boilerplate. In practice, you can get most of the benefit — clear intent, testable business logic, and honest domain language — with three focused tools: Actions, DTOs, and Value Objects. The trick is knowing where each one earns its keep and where it just adds noise.
Value Objects: Encode Rules, Not Just Data
A Value Object wraps a primitive and enforces its own invariants. It is immutable, comparable by value, and self-validating. The moment you stop passing raw strings for things like money, email addresses, or percentages, an entire class of bugs disappears.
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 and the database layer becomes transparent:
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,
];
}
}
DTOs: Structured Input, Not Anemic Arrays
A DTO is a typed container that moves validated data across layer boundaries. It replaces the $request->all() array that leaks HTTP concerns into your domain.
final readonly class CreateOrderData
{
public function __construct(
public int $customerId,
public Money $total,
public array $lineItems,
public ?string $couponCode = null,
) {}
public static function fromRequest(Request $request): self
{
return new self(
customerId: (int) $request->user()->id,
total: new Money($request->integer('amount_cents'), $request->string('currency')),
lineItems: $request->collect('items')->toArray(),
couponCode: $request->string('coupon') ?: null,
);
}
}
Keep DTOs dumb. No business logic, no database calls. Their job is transport.
Actions: One Class, One Responsibility
An Action encapsulates a single use-case. It accepts a DTO (or Value Objects directly), orchestrates domain logic, and returns a result. It is trivially testable because it has no HTTP or queue coupling.
final class CreateOrder
{
public function __construct(
private readonly OrderRepository $orders,
private readonly CouponService $coupons,
private readonly EventDispatcher $events,
) {}
public function execute(CreateOrderData $data): Order
{
$discount = $data->couponCode
? $this->coupons->resolve($data->couponCode)
: null;
$order = Order::create([
'customer_id' => $data->customerId,
'total' => $discount
? $data->total->subtract($discount->amount())
: $data->total,
'line_items' => $data->lineItems,
]);
$this->events->dispatch(new OrderCreated($order));
return $order;
}
}
Wire it in a controller with a single line:
public function store(StoreOrderRequest $request, CreateOrder $action): JsonResponse
{
$order = $action->execute(CreateOrderData::fromRequest($request));
return OrderResource::make($order)->response()->setStatusCode(201);
}
Where to Draw the Line
Not every field needs a Value Object. A user's first_name is fine as a string. Reserve Value Objects for concepts with rules — money, coordinates, email addresses, percentages, identifiers with a specific format.
Similarly, not every operation needs an Action class. A simple User::find() in a controller is not a sin. Reach for an Action when the logic involves multiple steps, side effects, or needs to be reused across HTTP, CLI, and queue contexts.
Key Takeaways
- Value Objects enforce invariants at construction time and eliminate primitive obsession.
- DTOs decouple HTTP input from domain logic; keep them readonly and free of behaviour.
- Actions are single-responsibility orchestrators — easy to unit-test, easy to reuse.
- Combine all three with Laravel's custom casts and form requests for a clean, layered flow.
- Resist the urge to create abstractions for their own sake; every layer must justify its existence.