The Problem With "DDD" in Most Laravel Codebases
Most teams reach for a DDD vocabulary — Actions, DTOs, Value Objects — and end up with three extra abstraction layers that do nothing except shuffle arrays between classes. The goal of this article is to show you what each concept is actually for, and how to implement each one with just enough code to earn its keep.
Value Objects: Enforce Invariants, Not Just Types
A Value Object is not a typed array. It is a small, immutable object whose equality is defined by its value, not its identity, and which enforces its own invariants at construction time.
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 persistence layer stays transparent:
class MoneyCast implements CastsAttributes
{
public function get($model, $key, $value, $attributes): Money
{
return new Money((int) $attributes['amount_in_cents'], $attributes['currency']);
}
public function set($model, $key, $value, $attributes): array
{
return [
'amount_in_cents' => $value->amountInCents,
'currency' => $value->currency,
];
}
}
Now $order->total is always a valid Money object — no validation scattered across controllers.
DTOs: Structured Input, Not Fancy Arrays
A DTO carries validated, typed data across a boundary. In Laravel, the natural boundary is the HTTP layer → domain layer. Use PHP 8 readonly properties and a static factory that reads from a FormRequest:
final readonly class CreateOrderData
{
public function __construct(
public int $customerId,
public Money $total,
public string $notes,
) {}
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')->toString(),
),
notes: $request->string('notes')->trim()->toString(),
);
}
}
No base class, no macro magic — just a plain PHP object that is trivially constructable in tests.
Actions: One Class, One Job
An Action encapsulates a single use-case. It is not a service class with ten methods. It is not a job. It is the orchestration layer between your DTO and your domain models.
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,
'notes' => $data->notes,
]);
$this->events->dispatch(new OrderCreated($order));
return $order;
}
}
Wire it through the service container and call it from your controller:
class OrderController extends Controller
{
public function store(CreateOrderRequest $request, CreateOrder $action): JsonResponse
{
$order = $action->execute(CreateOrderData::fromRequest($request));
return OrderResource::make($order)->response()->setStatusCode(201);
}
}
The controller has zero business logic. The action has zero HTTP knowledge. Both are independently testable.
Testing the Stack Without a Browser
it('creates an order and dispatches OrderCreated', function () {
Event::fake([OrderCreated::class]);
$data = new CreateOrderData(
customerId: 1,
total: new Money(5000, 'USD'),
notes: 'Rush delivery',
);
$order = app(CreateOrder::class)->execute($data);
expect($order->total->amountInCents)->toBe(5000);
Event::assertDispatched(OrderCreated::class);
});
No HTTP overhead, no form parsing — just domain logic under test.
Key Takeaways
- Value Objects enforce invariants at construction; pair them with Eloquent casts to keep models clean.
- DTOs are typed, immutable data carriers — static factory methods on
FormRequestkeep the boundary explicit. - Actions are single-responsibility orchestrators; inject dependencies via the constructor, not the
executemethod. - Avoid base classes and trait soup — each concept earns its keep through simplicity, not inheritance.
- The entire stack is unit-testable without booting HTTP or a browser.