Why DDD Concepts Matter Without Going Full Framework
Domain-driven design gets a bad reputation for ceremony. Aggregates, repositories, domain events, bounded contexts — the vocabulary alone can paralyse a team. But the core tactical patterns — value objects, data transfer objects, and single-action classes — are lightweight enough to drop into any Laravel project today, and they pay dividends immediately in readability and testability.
This article focuses on those three patterns, how they interact, and where the common mistakes are.
Value Objects: Encapsulate Meaning, Not Just Data
A value object wraps a primitive and enforces its invariants at construction time. Once created, it is immutable.
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 layer stays clean:
class MoneyCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes): Money
{
return new Money(
(int) $attributes['amount_in_cents'],
$attributes['currency'],
);
}
public function set($model, string $key, $value, array $attributes): array
{
if (!$value instanceof Money) {
throw new \InvalidArgumentException('Expected a Money instance.');
}
return [
'amount_in_cents' => $value->amountInCents,
'currency' => $value->currency,
];
}
}
Now $order->total is always a Money — never a raw integer you might accidentally display without formatting.
DTOs: Typed Input Boundaries
A DTO carries validated, typed data across a layer boundary. It is not a model, not a form request, and not a value object — it is a plain carrier.
final readonly class PlaceOrderData
{
public function __construct(
public int $customerId,
public Money $total,
public string $shippingAddress,
/** @var non-empty-list<OrderLineData> */
public array $lines,
) {}
public static function fromRequest(PlaceOrderRequest $request): self
{
return new self(
customerId: $request->integer('customer_id'),
total: new Money($request->integer('total_cents'), $request->string('currency')),
shippingAddress: $request->string('shipping_address'),
lines: array_map(
fn(array $line) => OrderLineData::fromArray($line),
$request->array('lines'),
),
);
}
}
Using readonly (PHP 8.2+) eliminates the need for getters and prevents mutation after construction.
Single-Action Classes: One Class, One Job
Actions replace fat service classes. Each action does exactly one thing and declares its dependencies explicitly.
final class PlaceOrderAction
{
public function __construct(
private readonly OrderRepository $orders,
private readonly PaymentGateway $payments,
private readonly EventDispatcher $events,
) {}
public function execute(PlaceOrderData $data): Order
{
$order = Order::create([
'customer_id' => $data->customerId,
'total_in_cents' => $data->total->amountInCents,
'currency' => $data->total->currency,
'shipping_address' => $data->shippingAddress,
]);
foreach ($data->lines as $line) {
$order->lines()->create($line->toArray());
}
$this->payments->charge($order);
$this->events->dispatch(new OrderPlaced($order));
return $order;
}
}
In the controller:
public function store(PlaceOrderRequest $request, PlaceOrderAction $action): JsonResponse
{
$order = $action->execute(PlaceOrderData::fromRequest($request));
return OrderResource::make($order)->response()->setStatusCode(201);
}
Laravel's service container resolves PlaceOrderAction automatically. No manual wiring needed.
Testing the Stack with Pest
Because each layer is isolated, tests are small and fast:
it('adds two money values of the same currency', function () {
$a = new Money(1000, 'USD');
$b = new Money(500, 'USD');
expect($a->add($b)->amountInCents)->toBe(1500);
});
it('throws when adding different currencies', function () {
$a = new Money(1000, 'USD');
$b = new Money(500, 'EUR');
expect(fn() => $a->add($b))->toThrow(\LogicException::class);
});
it('places an order and dispatches an event', function () {
Event::fake([OrderPlaced::class]);
$action = app(PlaceOrderAction::class);
$data = PlaceOrderData::fromRequest(fakeOrderRequest());
$order = $action->execute($data);
expect($order->total_in_cents)->toBe($data->total->amountInCents);
Event::assertDispatched(OrderPlaced::class);
});
Key Takeaways
- Value objects enforce invariants at construction and eliminate primitive obsession.
- DTOs create a typed boundary between HTTP input and domain logic — use
readonlyto prevent mutation. - Single-action classes keep domain logic discoverable, injectable, and independently testable.
- Custom Eloquent casts bridge value objects and the persistence layer without leaking domain rules into models.
- None of these patterns require a DDD framework — they are plain PHP classes resolved by Laravel's container.