Contextual DTOs and Value Objects in Laravel Without the Bloat
#laravel #ddd #php8.3 #clean-architecture #eloquent

Contextual DTOs and Value Objects in Laravel Without the Bloat

1 min read Mohamed Said Mohamed Said

The Problem With Bloated DDD Toolkits

Most DDD tutorials for Laravel end with you installing a 40-class package that wraps every primitive in an abstract hierarchy. The result is more ceremony than clarity. PHP 8.3 readonly classes, combined with a few focused conventions, give you everything you actually need.


DTOs: Readonly Classes as Input Contracts

A DTO's only job is to carry validated, typed data across a boundary — from a controller into an action, or from a queue payload into a handler. PHP 8.3 readonly classes are a perfect fit.

<?php

namespace App\Orders\Data;

final readonly class PlaceOrderData
{
    public function __construct(
        public int    $customerId,
        public string $currencyCode,
        /** @var non-empty-list<OrderLineData> */
        public array  $lines,
    ) {}

    public static function fromRequest(\Illuminate\Http\Request $request): self
    {
        $validated = $request->validate([
            'customer_id'  => ['required', 'integer', 'exists:customers,id'],
            'currency'     => ['required', 'string', 'size:3'],
            'lines'        => ['required', 'array', 'min:1'],
            'lines.*.sku'  => ['required', 'string'],
            'lines.*.qty'  => ['required', 'integer', 'min:1'],
        ]);

        return new self(
            customerId:   $validated['customer_id'],
            currencyCode: strtoupper($validated['currency']),
            lines:        array_map(
                fn ($l) => OrderLineData::from($l),
                $validated['lines'],
            ),
        );
    }
}

No base class, no magic fill(), no reflection. The fromRequest factory keeps validation co-located with the shape it produces.


Value Objects: Encapsulate Rules, Not Just Data

A value object enforces a domain invariant at construction time. If you can create an invalid instance, it is not a value object — it is a struct.

<?php

namespace App\Shared\ValueObjects;

final readonly class Money
{
    public function __construct(
        public int    $amount,   // minor units (cents)
        public string $currency, // ISO 4217
    ) {
        if ($amount < 0) {
            throw new \DomainException('Money amount cannot be negative.');
        }

        if (strlen($currency) !== 3) {
            throw new \DomainException('Currency must be an ISO 4217 code.');
        }
    }

    public function add(self $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new \DomainException('Cannot add different currencies.');
        }

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

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

Because the class is readonly, every "mutation" returns a new instance. Equality is structural: two Money objects with the same amount and currency are identical by definition.


Casting Value Objects Into Eloquent

Persisting a value object through Eloquent without losing the invariant is straightforward with a custom cast.

<?php

namespace App\Shared\Casts;

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

/** @implements CastsAttributes<Money, never> */
class MoneyCast implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): Money
    {
        return new Money(
            amount:   (int) $attributes[$key . '_amount'],
            currency: (string) $attributes[$key . '_currency'],
        );
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): array
    {
        if (! $value instanceof Money) {
            throw new \InvalidArgumentException('Expected a Money instance.');
        }

        return [
            $key . '_amount'   => $value->amount,
            $key . '_currency' => $value->currency,
        ];
    }
}

In the model:

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

Now $order->price is always a valid Money object — never a raw integer that could be misused.


Keeping Actions Thin

With typed DTOs flowing in and value objects flowing out, actions stay focused:

<?php

namespace App\Orders\Actions;

use App\Orders\Data\PlaceOrderData;
use App\Orders\Models\Order;

final class PlaceOrderAction
{
    public function execute(PlaceOrderData $data): Order
    {
        return Order::create([
            'customer_id' => $data->customerId,
            'price'       => new \App\Shared\ValueObjects\Money(0, $data->currencyCode),
        ]);
    }
}

Key Takeaways

  • PHP 8.3 readonly classes give you immutable DTOs and value objects with zero dependencies.
  • Validate at the boundary (controller/job) and pass only valid, typed data inward.
  • Value objects enforce invariants in their constructor — invalid state is impossible to construct.
  • Custom Eloquent casts bridge the gap between domain objects and the database without leaking primitives into your models.
  • No base class needed — composition and static factories beat inheritance for this use case.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Should I use a package like spatie/data or Laravel-DTO instead of rolling my own?
Spatie's `laravel-data` is excellent when you need automatic validation, transformation pipelines, or TypeScript type generation. For simpler domains, plain readonly classes with static factory methods have zero overhead and are easier to trace. Choose based on the complexity of your data layer, not habit.
Q02 How do I test value objects that throw domain exceptions?
With Pest, use `expect(fn () => new Money(-1, 'USD'))->toThrow(\DomainException::class)`. Because value objects have no dependencies, they are the easiest unit to test — no mocking required.
Q03 Can readonly classes be used inside Laravel queue payloads?
Yes. Laravel serializes job properties via PHP's native serialization. Readonly classes serialize and unserialize correctly as of PHP 8.1+. Just ensure all constructor arguments are also serializable types.

Continue reading

More Articles

View all