DDD Value Objects and DTOs in Laravel Without the Bloat
#laravel #ddd #php #architecture #clean-code

DDD Value Objects and DTOs in Laravel Without the Bloat

1 min read Mohamed Said Mohamed Said

The Problem With Anemic Domain Models

Most Laravel codebases pass raw arrays or plain Eloquent models between layers. This works until it doesn't: you find yourself writing $data['email'] in five controllers, validating the same format in three places, and debugging a null that should never have been allowed in.

Domain-driven design offers two lightweight tools that fix this without requiring a framework: value objects and DTOs. PHP 8.3 readonly classes make both feel native.

Value Objects: Identity Through Value

A value object has no identity beyond its data. Two Money instances with the same amount and currency are equal. They are immutable by definition.

<?php

namespace App\Domain\Billing\ValueObjects;

use InvalidArgumentException;

final readonly 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 InvalidArgumentException('Currency mismatch.');
        }

        return new self($this->amountInCents + $other->amountInCents, $this->currency);
    }

    public function equals(self $other): bool
    {
        return $this->amountInCents === $other->amountInCents
            && $this->currency === $other->currency;
    }

    public function format(): string
    {
        return number_format($this->amountInCents / 100, 2) . ' ' . $this->currency;
    }
}

The constructor is the validation boundary. If a Money object exists, it is valid — no need to re-check downstream.

Casting Value Objects in Eloquent

Bridge the gap between your domain and persistence with a custom cast:

<?php

namespace App\Domain\Billing\Casts;

use App\Domain\Billing\ValueObjects\Money;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;

class MoneyCast implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): Money
    {
        return new Money(
            amountInCents: (int) $attributes['amount_in_cents'],
            currency: $attributes['currency'],
        );
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): array
    {
        return [
            'amount_in_cents' => $value->amountInCents,
            'currency'        => $value->currency,
        ];
    }
}

On the model:

protected $casts = [
    'price' => MoneyCast::class,
];

Now $order->price returns a Money instance, not a raw integer.

DTOs: Typed Input Boundaries

DTOs carry data across layer boundaries — from HTTP into your application layer. They are not value objects; they have no behaviour and no equality semantics. Their job is to replace array $data with something a static analyser can reason about.

<?php

namespace App\Domain\Orders\DataTransferObjects;

use App\Domain\Billing\ValueObjects\Money;

final readonly class CreateOrderData
{
    public function __construct(
        public readonly int    $customerId,
        public readonly Money  $total,
        public readonly string $shippingAddress,
        public readonly ?string $couponCode = null,
    ) {}

    public static function fromRequest(\Illuminate\Http\Request $request): self
    {
        return new self(
            customerId:      (int) $request->validated('customer_id'),
            total:           new Money(
                                 amountInCents: (int) $request->validated('amount_in_cents'),
                                 currency:      $request->validated('currency'),
                             ),
            shippingAddress: $request->validated('shipping_address'),
            couponCode:      $request->validated('coupon_code'),
        );
    }
}

The controller becomes trivial:

public function store(CreateOrderRequest $request, CreateOrderAction $action): JsonResponse
{
    $order = $action->execute(CreateOrderData::fromRequest($request));

    return response()->json(new OrderResource($order), 201);
}

Why Not Spatie Data?

Spatie's laravel-data is excellent, but it adds reflection-heavy hydration, casts, and pipeline overhead. For teams that want zero magic and full IDE transparency, plain readonly classes are faster to understand, easier to test, and carry no hidden cost.

Use spatie/laravel-data when you need automatic API resource generation or complex nested casting. Use plain readonly classes when your domain layer must stay framework-agnostic.

Takeaways

  • Value objects enforce invariants at construction time — if the object exists, it is valid.
  • DTOs replace array $data with typed, statically analysable input boundaries.
  • PHP 8.3 readonly classes give you immutability for free without boilerplate.
  • Custom Eloquent casts bridge value objects to persistence without leaking domain logic into models.
  • Keep DTOs thin — no business logic, no service calls, just typed data transport.
  • Avoid over-engineering: one value object per meaningful concept, not one per column.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Should every Eloquent attribute become a value object?
No. Only wrap attributes that carry domain rules — like Money, Email, or PhoneNumber. Plain strings and integers that have no invariants don't need a value object wrapper.
Q02 Can I use readonly classes with Eloquent model filling?
Readonly classes are for your domain and application layers, not for Eloquent models themselves. Use custom casts to convert between the Eloquent model's raw attributes and your value objects at the boundary.
Q03 When should I choose spatie/laravel-data over plain DTOs?
Choose spatie/laravel-data when you need automatic validation, nested casting, or want to reuse the same class as an API resource. Use plain readonly classes when you want zero framework coupling in your domain layer.

Continue reading

More Articles

View all