Domain Layer Purity in Laravel 10, Entities, Value Objects, and Where Most Codebases Lose It
TL;DR — A domain entity is not an Eloquent model in a different folder / Value objects should be the default; primitives are a code smell / If your test for a domain class needs the Laravel kernel, the class is not really domain
The single most common failure mode I see in Laravel codebases that “do DDD” is the slow merge between the domain layer and Eloquent. It starts innocently: someone gives User a markVerified() method. Then someone else gives it a static findByEmail(). Then someone adds a relationship. Six months later the “domain entity” is a 600-line Eloquent model and the domain folder is dead weight.
The discipline you need to avoid this is small but specific. There are about five rules. If you hold them, the domain layer stays honest. If you bend any of them, the merger starts and it is hard to reverse.
I am going to use a real example from a SaaS billing codebase. Same flavour of domain we touched in the ports and adapters post, but this time in PHP, with all the framework discipline you need on a Laravel 10 codebase specifically.
The Five Rules
These are the ones I enforce on code review:
- No
Illuminate\*imports inside the domain namespace. None. Not Carbon, not Collection, not even Str. PHP has DateTimeImmutable; you do not need Carbon in domain code. - No static persistence methods on entities. No
User::find(), noInvoice::create(). Construction goes through the constructor or a named factory; persistence goes through a repository. - Value objects for any concept that has a name in the business. Email, Money, PhoneNumber, InvoiceNumber. Strings are for data interchange, not for domain logic.
- Entities expose intent, not data.
$invoice->markPaid(), not$invoice->status = 'paid'. Getters are fine; public setters are a smell. - Domain tests do not extend
TestCase. They extendPHPUnit\Framework\TestCase. If you need Laravel’s container or DB, you are testing the application layer, not the domain.
That is it. Five rules. The rest is taste.
A Real Entity
Start with what an entity actually looks like when you take these seriously:
<?php
declare(strict_types=1);
namespace App\Domain\Billing;
use App\Domain\Billing\Events\InvoicePaid;
use App\Domain\Billing\Exceptions\InvoiceAlreadyPaid;
use App\Domain\Billing\Exceptions\InvoiceVoided;
use App\Domain\Shared\Clock;
use DateTimeImmutable;
final class Invoice
{
private InvoiceStatus $status;
private ?DateTimeImmutable $paidAt = null;
private Money $lateFee;
/** @var list<InvoiceLine> */
private array $lines;
public function __construct(
private readonly InvoiceId $id,
private readonly CustomerId $customerId,
private readonly InvoiceNumber $number,
private readonly DateTimeImmutable $issuedAt,
private readonly DateTimeImmutable $dueAt,
Money $subtotal,
array $lines,
) {
$this->status = InvoiceStatus::Open;
$this->lateFee = Money::zero($subtotal->currency());
$this->lines = array_values($lines);
$this->subtotal = $subtotal;
}
public function markPaid(Clock $clock): InvoicePaid
{
match ($this->status) {
InvoiceStatus::Paid => throw new InvoiceAlreadyPaid($this->id),
InvoiceStatus::Voided => throw new InvoiceVoided($this->id),
InvoiceStatus::Open => null,
};
$now = $clock->now();
if ($now > $this->dueAt) {
$this->lateFee = $this->subtotal->percent(5);
}
$this->status = InvoiceStatus::Paid;
$this->paidAt = $now;
return new InvoicePaid(
invoiceId: $this->id,
customerId: $this->customerId,
paidAt: $now,
amount: $this->totalAmount(),
);
}
public function totalAmount(): Money
{
return $this->subtotal->add($this->lateFee);
}
public function id(): InvoiceId { return $this->id; }
public function status(): InvoiceStatus { return $this->status; }
public function isPaid(): bool { return $this->status === InvoiceStatus::Paid; }
// ...
}
Things to notice:
- The constructor enforces invariants. You cannot construct a half-built invoice.
markPaidhas business logic inside it — the late-fee calculation, the state transition. This is the point of having a domain layer.matchon the status enum handles every case explicitly. PHP 8.1+ enums are excellent for this. If we add aDisputedstatus later, thismatchbecomes a compile-time TODO.- Clock is injected, not pulled from a global.
now()in tests is whatever you say it is. - The method returns a domain event. The application layer publishes it; the entity just declares what happened.
Value Objects Are the Real Win
If you do nothing else from this post, do this: replace primitives with value objects. The amount of bugs this prevents is hard to overstate.
<?php
declare(strict_types=1);
namespace App\Domain\Billing;
use InvalidArgumentException;
final readonly class Money
{
private function __construct(
public int $minorUnits,
public Currency $currency,
) {}
public static function of(int $minorUnits, Currency $currency): self
{
return new self($minorUnits, $currency);
}
public static function zero(Currency $currency): self
{
return new self(0, $currency);
}
public function add(Money $other): self
{
$this->assertSameCurrency($other);
return new self($this->minorUnits + $other->minorUnits, $this->currency);
}
public function subtract(Money $other): self
{
$this->assertSameCurrency($other);
return new self($this->minorUnits - $other->minorUnits, $this->currency);
}
public function percent(int $pct): self
{
return new self(intdiv($this->minorUnits * $pct, 100), $this->currency);
}
public function isZero(): bool { return $this->minorUnits === 0; }
public function currency(): Currency { return $this->currency; }
private function assertSameCurrency(Money $other): void
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException(sprintf(
'Currency mismatch: %s vs %s',
$this->currency->code,
$other->currency->code,
));
}
}
}
Now Money::add(Money) cannot ever silently add USD to EUR. The cost of this class is 40 lines once; the benefit is years of “wait, why is the invoice in dollars but the line is in euros” never happening. We use int for minor units rather than floats — the PHP docs on floating point cover why floats and money do not mix.
Same idea for InvoiceNumber:
final readonly class InvoiceNumber
{
private const PATTERN = '/^INV-\d{4}-\d{6}$/';
public function __construct(public string $value)
{
if (!preg_match(self::PATTERN, $value)) {
throw new InvalidArgumentException("Invalid invoice number: $value");
}
}
public function __toString(): string { return $this->value; }
}
Now anywhere in the codebase that wants an invoice number takes InvoiceNumber as a parameter type. The validation happens at construction. There is no path through the system where a malformed invoice number reaches the database.
PHP 8.2 readonly classes (the whole class, not just properties) are the perfect fit for value objects. They prevent the mutability mistakes that used to require manual discipline.
The Eloquent Adapter
This is where Eloquent lives, and it is allowed to be ugly. Its job is to translate between rows and entities. Nothing else:
<?php
declare(strict_types=1);
namespace App\Infrastructure\Billing\Persistence;
use App\Domain\Billing\Currency;
use App\Domain\Billing\CustomerId;
use App\Domain\Billing\Invoice;
use App\Domain\Billing\InvoiceId;
use App\Domain\Billing\InvoiceLine;
use App\Domain\Billing\InvoiceNumber;
use App\Domain\Billing\InvoiceStatus;
use App\Domain\Billing\Money;
use DateTimeImmutable;
final class InvoiceMapper
{
public static function toDomain(InvoiceEloquentModel $row): Invoice
{
$currency = new Currency($row->currency);
$lines = $row->lines->map(fn ($line) => new InvoiceLine(
description: $line->description,
amount: Money::of($line->amount_minor, $currency),
quantity: $line->quantity,
))->all();
$invoice = new Invoice(
id: new InvoiceId($row->id),
customerId: new CustomerId($row->customer_id),
number: new InvoiceNumber($row->number),
issuedAt: new DateTimeImmutable($row->issued_at),
dueAt: new DateTimeImmutable($row->due_at),
subtotal: Money::of($row->subtotal_minor, $currency),
lines: $lines,
);
// re-hydrate state. private property; use Reflection or a named constructor.
// I prefer a named constructor:
return $row->status === 'paid'
? $invoice->withPaidState(new DateTimeImmutable($row->paid_at))
: $invoice;
}
public static function hydrate(InvoiceEloquentModel $row, Invoice $invoice): void
{
$row->id = $invoice->id()->toString();
$row->status = $invoice->status()->value;
$row->subtotal_minor = $invoice->subtotal()->minorUnits;
$row->paid_at = $invoice->paidAt()?->format(DATE_ATOM);
$row->late_fee_minor = $invoice->lateFee()->minorUnits;
// ... and so on
}
}
The rehydration case is the awkward one. You have an Invoice that you saved last week as paid, and now you want to load it back from the database in that state. The two clean options:
- A named constructor on the entity (
Invoice::rehydrate(...)) that takes all internal state explicitly. This is what I prefer. - Reflection from the mapper to set private properties. Faster to write, harder to reason about.
The “factory” approach is what most teams converge on. The entity has Invoice::open(...) for new instances and Invoice::rehydrate(...) for ones loaded from storage. The rehydrate constructor does no validation beyond shape — it trusts that what was saved was valid.
Testing Without the Framework
This is the payoff. A domain test for Invoice::markPaid:
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Billing;
use App\Domain\Billing\Currency;
use App\Domain\Billing\CustomerId;
use App\Domain\Billing\Exceptions\InvoiceAlreadyPaid;
use App\Domain\Billing\Invoice;
use App\Domain\Billing\InvoiceId;
use App\Domain\Billing\InvoiceNumber;
use App\Domain\Billing\Money;
use App\Domain\Shared\FixedClock;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
final class InvoiceTest extends TestCase
{
public function test_marking_paid_after_due_date_applies_late_fee(): void
{
$usd = new Currency('USD');
$invoice = new Invoice(
id: new InvoiceId('inv_01'),
customerId: new CustomerId('cust_01'),
number: new InvoiceNumber('INV-2023-000001'),
issuedAt: new DateTimeImmutable('2023-09-01'),
dueAt: new DateTimeImmutable('2023-09-15'),
subtotal: Money::of(10_000, $usd),
lines: [],
);
$clock = new FixedClock(new DateTimeImmutable('2023-10-01'));
$event = $invoice->markPaid($clock);
$this->assertTrue($invoice->isPaid());
$this->assertSame(10_500, $invoice->totalAmount()->minorUnits); // 5% late fee
$this->assertSame(10_500, $event->amount->minorUnits);
}
public function test_cannot_mark_already_paid_invoice_as_paid(): void
{
$invoice = $this->openInvoice();
$invoice->markPaid(new FixedClock(new DateTimeImmutable()));
$this->expectException(InvoiceAlreadyPaid::class);
$invoice->markPaid(new FixedClock(new DateTimeImmutable()));
}
// ... helper builders
}
extends TestCase, not extends \Tests\TestCase. No RefreshDatabase. No service container. Runs in 5ms. You can run thousands of these on every save and not notice.
Common Pitfalls
The mistakes I have made and seen made, in declining order of damage:
- Carbon in the domain.
Carbon\Carbonis Laravel’s default datetime, but it is a third-party package with its own API and its own quirks. UseDateTimeImmutablein the domain and convert at the boundary. Laravel 10’s mutators handle this cleanly. - Static factories that hit the database.
Invoice::createForCustomer($id)that does an INSERT is not a factory; it is a service masquerading as a constructor. Construction is in-memory only. Persistence is repository. - Entities that implement framework interfaces.
class Invoice implements Arrayable, Jsonableties your domain to Laravel. If you need a JSON view, that is a presentation concern — put it in a Resource. - Public getters for everything. Each public getter is a tiny commitment. The fewer you have, the more freedom you have to change internal state. Ask whether the caller needs the field, or whether the caller is doing logic that should be on the entity.
- Domain events as arrays.
event(['name' => 'invoice.paid', 'id' => $id]). Now refactoring the field name is a grep across the whole codebase. Domain events are classes. Always. - Setting state from outside via reflection in the application layer. If you find yourself reaching for reflection outside the persistence mapper, the entity API is wrong.
The Carbon point catches people who came from a pure-Laravel background. It feels backwards. Carbon is good. Why not use it? Because the moment you take a Carbon dependency in the domain, your domain knows about a third-party library that has had four major-version migrations in the last six years. Save yourself.
Wrapping Up
Domain layer purity is a discipline, not a framework choice. PHP 8.2 has every feature you need — readonly classes, enums, native types, first-class callable syntax — to write a domain layer that is more expressive than what most “enterprise” Java codebases produce. The hard part is the rules, not the syntax. Hold the five rules and the rest follows.
Next post I will get into the dependency injection mechanics specifically — how Laravel’s container interacts with this style, and the patterns I use to keep the wiring honest. Spoiler: contextual bindings are doing more work in my codebases than people realise.