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 $datawith typed, statically analysable input boundaries. - PHP 8.3
readonlyclasses 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.