An order placed in 2024 is still in the database:
number=LEGACY-42
status=placed
fulfil_by=2024-12-20T10:00:00Z
version=7Today, new web orders need a number beginning with WEB- and a fulfilment
deadline in the future. Pass that stored row through today’s creation factory
and one of two bad things happens. The factory rejects valid history, or the
load pretends that placement happened again and records a new OrderPlaced
fact.
Loading is not a second attempt at creation. Creation decides whether a new transition is allowed now. Reconstitution restores state which was accepted before, without claiming that the transition just occurred.
That distinction does not give stored data a free pass. An unknown status, empty identity, impossible lifecycle, or total which disagrees with its lines should still fail. The load path applies reconstruction rules instead of today’s admission policy.
Two paths answer two different questions
The reproducible order fixture fixes its clock at 22 July 2026. Its creation path asks whether a new order may be placed at that time:
public static function placeNew(
string $id,
string $number,
string $fulfilBy,
string $now,
string $currency,
array $lines,
): self {
if (!str_starts_with($number, 'WEB-')) {
throw new DomainException(
'New order number must begin with WEB-.',
);
}
if ($fulfilBy <= $now) {
throw new DomainException(
'New fulfilment deadline must be in the future.',
);
}
$order = new self(/* complete new state */, version: 0);
$order->recordedFacts[] = [
'type' => 'OrderPlaced',
'order_id' => $id,
];
return $order;
}The prefix and future-deadline checks are admission policy. Version zero and the recorded fact are consequences of accepting a new decision.
The reconstitution path has a different input. It receives the complete state which storage claims already exists:
public static function reconstitute(
string $id,
string $number,
string $status,
?string $fulfilBy,
string $currency,
array $lines,
int $storedTotalMinor,
int $version,
): self {
return new self(
$id,
$number,
$status,
$fulfilBy,
$currency,
$lines,
$storedTotalMinor,
$version,
);
}There is no clock because loading does not ask whether the deadline is in the future. There is no current number policy because the old number already identifies an accepted order. There is no event bus, repository, or Eloquent record because none belongs to the object’s state.
In the fixture, placeNew() refuses LEGACY-42 and its past deadline.
reconstitute() restores both, along with placed status, one line, and version
seven. Its recorded-fact list remains empty.
Those outcomes are not a claim that every old deadline or number should load. They expose one useful test: can a policy change without making previously valid state unreadable or making a read look like a new business decision?
Reconstruction validates meaning, not current eligibility
“Loading skips validation” is the wrong model. It replaces a precise boundary with trust in whatever happens to be stored.
The fixture routes both entry points through a private constructor which protects structural meaning. It refuses:
- an empty identity;
- a status outside
draftandplaced; - a negative stored version;
- a placed order without a deadline;
- a draft order with a deadline;
- a placed order without lines;
- a line in a different currency; and
- a stored total which does not equal the line total.
None of those checks asks whether the order would be admitted today. They ask
whether the reconstructed object can honestly mean what the row says it means.
A placed order without lines is contradictory in this model. A 2024 deadline
is merely historical.
The difference matters most when a check moves over time. Imagine that new
orders originally accepted any non-empty number, then moved to the WEB-
prefix. Putting the prefix check in the shared reconstruction path turns a
creation-policy change into a data migration requirement. Sometimes that is
intentional. Often it is an accidental consequence of having only one way to
build the object.
The reverse mistake is just as dangerous. Removing every check so old rows continue to load can produce an object with an invented identity, a defaulted status, or a recalculated total. It looks usable, but it no longer reports what storage contained.
Reconstitution should preserve meaningful history and reject history the current code cannot interpret. It should not make the history look reasonable.
A mapper must preserve the failure too
The mapper from the previous article in this series is where stored scalars become domain values. That makes it responsible for supplying complete state, not for deciding that missing state is probably harmless.
The fixture’s mapper reads an order and its lines, converts the records, then
calls Order::reconstitute():
try {
return Order::reconstitute(
id: (string) $root['id'],
number: (string) $root['number'],
status: (string) $root['status'],
fulfilBy: $root['fulfil_by'],
currency: (string) $root['currency'],
lines: $lines,
storedTotalMinor: (int) $root['total_minor'],
version: (int) $root['version'],
);
} catch (Throwable $exception) {
throw LoadFailed::forRecord($id, $exception);
}A malformed status therefore becomes a load failure containing both the record identity and the original reason. Currency and total disagreements fail the same way. The corrupt currency row remains unchanged after the attempt.
That last detail rules out a tempting recovery strategy: recalculate or normalize while loading. A read should not quietly rewrite the evidence needed to diagnose an incompatible row. Depending on the system, the next step may be a migration, quarantine, an operator decision, or an explicit compatibility adapter. The mapper cannot choose among those without knowing who owns the data and what loss is acceptable.
Calling placeNew() and clearing its recorded facts afterwards is not an
equivalent shortcut. The factory may already have rejected old policy,
generated identity, normalized a value, or initialized version zero. Removing
one visible effect does not undo the decision path which produced the object.
Framework hydration has its own contract
Eloquent already loads models without asking application code to call a public
constructor for every row. Laravel 13’s pinned
Model::newFromBuilder() implementation creates an
existing model instance, assigns raw attributes, sets its connection, and
fires the retrieved model event.
That is valid Active Record hydration. It is not evidence that a separate domain aggregate’s creation factory ran, nor should it be. Eloquent is rebuilding an Eloquent model according to Eloquent’s contract.
If the Eloquent model is the domain object, a separate reconstitute() method
is not mandatory just to satisfy a pattern. Casts, model events, accessors, and
the model’s own invariants may already form a coherent boundary. Check their
actual load semantics before adding another object and mapper.
If the application does have a separate aggregate, raw model hydration solves only the storage side. The mapper still needs an explicit contract for turning those attributes and relationships into meaningful domain state. Treating an Eloquent record and a domain object as interchangeable simply moves the translation into scattered call sites.
The name is useful only when the split is real
reconstitute() is not the only honest API. One public constructor can accept
complete state while a separate factory owns new-order policy. A serializer or
reflection hydrator can work if it reports missing fields and verifies the
finished object. A migration can remove support for old state before the new
code is deployed.
The named path earns its place when it makes a consequential distinction visible:
- creation uses current policy while loading accepts meaningful legacy state;
- creation records a fact while loading records nothing;
- creation initializes identity or version while loading preserves them; or
- load failures need different operational handling from refused commands.
If both paths merely assign the same values and have the same failure semantics, two names add ceremony rather than information.
The fixture tests the distinction with eight mutations. It fails if loading is
routed through creation, reconstitution records OrderPlaced, current deadline
policy leaks into loading, an unknown status is accepted, an empty identity is
defaulted, a version is reset, a false total is recalculated, or the mapper
creates and then clears facts. Those controls reproduce from the committed
evidence tree.
The resulting rule is small. Use creation to decide something new under the policy in force now. Use reconstitution to restore complete, meaningful state without replaying that decision. When stored state no longer has a meaning the code can defend, fail with enough evidence to handle it deliberately.