Index

Loading State Is Not Creating It Again

An order placed in 2024 is still in the database:

number=LEGACY-42
status=placed
fulfil_by=2024-12-20T10:00:00Z
version=7

Today, 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 draft and placed;
  • 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.