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.