Why DDD Primitives Without the Ceremony
Domain-Driven Design carries a reputation for ceremony: aggregates, repositories, domain events, bounded contexts—all before you ship a single feature. In practice, most Laravel teams benefit from just three DDD primitives applied consistently: Actions, DTOs, and Value Objects. Used together they eliminate fat controllers, anemic models, and the "where does this logic live?" debate.
This article is opinionated. We skip the theory and go straight to patterns that work in production Laravel codebases.
Value Objects: Encapsulate Meaning, Not Just Data
A Value Object wraps a primitive and enforces its own invariants. It is immutable and compared by value, not identity.
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 stays clean:
class MoneyCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes): Money
{
return new Money((int) $value, $attributes['currency']);
}
public function set($model, string $key, $value, array $attributes): array
{
return ['amount_in_cents' => $value->amountInCents, 'currency' => $value->currency];
}
}
Now $order->total returns a Money instance everywhere, not a raw integer.
DTOs: Typed Input Boundaries
A DTO (Data Transfer Object) carries validated, typed data across layer boundaries. It is not a model. It is not a form request. It is a plain PHP object that makes the shape of data explicit.
final readonly class CreateOrderData
{
public function __construct(
public int $customerId,
public Money $total,
public string $notes,
public \DateTimeImmutable $scheduledAt,
) {}
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')),
notes: $request->string('notes'),
scheduledAt: new \DateTimeImmutable($request->string('scheduled_at')),
);
}
}
Using PHP 8.2+ readonly classes means zero boilerplate for immutability. The fromRequest factory keeps the HTTP layer out of your domain.
Actions: Single-Responsibility Use Cases
An Action is an invokable class that executes one use case. It accepts a DTO, coordinates domain objects, and returns a result. No static methods, no traits, no magic.
final class CreateOrderAction
{
public function __construct(
private readonly OrderRepository $orders,
private readonly EventBus $events,
) {}
public function execute(CreateOrderData $data): Order
{
$order = Order::create([
'customer_id' => $data->customerId,
'amount_in_cents' => $data->total->amountInCents,
'currency' => $data->total->currency,
'notes' => $data->notes,
'scheduled_at' => $data->scheduledAt,
]);
$this->events->dispatch(new OrderCreated($order));
return $order;
}
}
The controller becomes trivial:
class OrderController extends Controller
{
public function store(CreateOrderRequest $request, CreateOrderAction $action): JsonResponse
{
$order = $action->execute(CreateOrderData::fromRequest($request));
return OrderResource::make($order)->response()->setStatusCode(201);
}
}
Laravel's service container resolves CreateOrderAction automatically, including its dependencies.
Avoiding Bloat: The Rules
- One Action per use case. Resist the urge to add
$modeflags or optional parameters. - DTOs are not validated inside themselves. Validation belongs in Form Requests or dedicated validators before the DTO is constructed.
- Value Objects throw on invalid input. They are the last line of domain invariant enforcement.
- Do not create a DTO for every array. Only introduce one when the shape crosses a layer boundary or is reused in multiple Actions.
- Actions are not jobs. If you need async execution, dispatch a job that instantiates and calls the Action.
Takeaways
- Value Objects enforce domain rules at the type level and pair cleanly with Eloquent casts.
- DTOs make layer boundaries explicit and eliminate primitive obsession in method signatures.
- Actions give each use case a single home, making testing and refactoring straightforward.
- PHP 8.2
readonlyclasses remove boilerplate from DTOs at zero runtime cost. - Start with these three primitives before reaching for repositories, aggregates, or event sourcing.