Index

A Constructor Should Leave a Usable Object

This Subscription exists before it can safely answer a question:

$subscription = new Subscription();
$subscription->id = $ids->next();
$subscription->customerId = $customerId;
$subscription->plan = $plans->get($planCode);
$subscription->status = 'trial';
$subscription->startsAt = $clock->now();
$subscription->trialEndsAt = $subscription->startsAt->modify('+14 days');

Between the first and last line, another method can observe an object without a plan, status, or start time. Forget the last assignment and an active subscription can carry a trial end. Reorder the calls and identity may be consumed for a plan which does not exist.

Adding a factory does not automatically repair this. Neither does replacing new Subscription() with Subscription::create(). The useful question is which boundary owns each decision.

A constructor should leave complete state. A named constructor should express a distinct creation policy. An external factory should resolve collaborators before choosing that policy. Framework hydration should keep its own contract when it cannot satisfy the domain contract.

Those are four jobs, not four competing spellings for object creation.

The dangerous state is the one the type permits

The reproduction includes a mutable control similar to the opening example. Calling snapshot() immediately after construction fails because required properties have not been initialized. Assigning every field does not solve the deeper problem: the control also accepts status=active alongside a non-null trial end.

The invalid state is possible because callers assemble the invariant. Every caller must know which fields are required, which combinations are legal, and which assignment must happen before the next method call.

The strict version moves that knowledge behind one private boundary:

private function __construct(
    private SubscriptionId $id,
    private CustomerId $customerId,
    private PlanCode $plan,
    private SubscriptionStatus $status,
    private DateTimeImmutable $startsAt,
    private ?DateTimeImmutable $trialEndsAt,
    private array $recordedEvents,
) {
    if ($status === SubscriptionStatus::Trial
        && ($trialEndsAt === null || $trialEndsAt <= $startsAt)) {
        throw new InvalidSubscriptionState(
            'A trial subscription needs an end after its start.',
        );
    }

    if ($status !== SubscriptionStatus::Trial && $trialEndsAt !== null) {
        throw new InvalidSubscriptionState(
            'Only a trial subscription may have a trial end.',
        );
    }
}

Every successful call returns an object which can immediately produce a stable snapshot and expose its recorded facts. There is no supported half-built lifecycle and no setter which can introduce one later.

The private visibility is incidental. One complete public constructor would work when there is one unambiguous input shape. The principle is that a successful construction path finishes the state boundary before returning.

The PHP manual describes constructors as suitable for initialization needed before an object is used. That is a language mechanism, not a domain design rule. PHP will happily run an empty constructor and leave public fields for the caller. The domain contract has to say what “initialized” means.

A name should expose a policy difference

The fixture has two ways to start a new subscription:

Subscription::startTrial(
    $id,
    $customerId,
    $plan,
    $startsAt,
    trialDays: 14,
);

Subscription::startPaid(
    $id,
    $customerId,
    $plan,
    $startsAt,
);

startTrial() calculates and validates a trial end. startPaid() establishes active state without one. Both record one SubscriptionStarted fact. The names remove a nullable trial argument and make the admitted policy visible at the call site.

That distinction earns two entry points. A static create() which accepts the same parameters and forwards them to new self(...) would add ceremony, not meaning. Named constructors help when they identify different policies, input forms, or lifecycle transitions. They are not inherently safer than new.

The third path is reconstitute(). It accepts an already interpreted status and stored trial end, preserves those values, and records no new fact. It still rejects structurally contradictory state.

That path exists because loading historical state is not a new subscription decision. The preceding article, Loading State Is Not Creating It Again, covers that distinction in depth. Here it establishes a narrower point: every path may share a complete state boundary without pretending to have the same policy.

A factory earns its name by collaborating

The named constructors are deliberately pure. They receive an identity, a plan, and a time. They do not resolve the service container, query a catalog, generate randomness, or read the system clock.

The application still needs those answers. SubscriptionFactory coordinates three explicit collaborators:

PlanCatalog
SubscriptionIdGenerator
Clock

Its order is observable:

try {
    $plan = $this->plans->get($planCode);
} catch (UnknownPlan $exception) {
    throw SubscriptionCreationFailed::unknownPlan($exception);
} catch (PlanCatalogUnavailable $exception) {
    throw SubscriptionCreationFailed::catalogUnavailable($exception);
}

$id = $this->ids->next();
$startsAt = $this->clock->now();

return $plan->trialDays === null
    ? Subscription::startPaid($id, $customerId, $plan->code, $startsAt)
    : Subscription::startTrial(
        $id,
        $customerId,
        $plan->code,
        $startsAt,
        $plan->trialDays,
    );

A trial plan and a paid plan each cause one lookup, one identity generation, and one clock read. An unknown plan and an unavailable catalog cause one lookup, no identity generation, and no clock read. The two failures remain distinct.

These checks are not an argument for testing a prescribed mock call script. They protect consequences. Generating an ID before a refused plan wastes a value and may create a misleading audit gap. Reading the clock twice can give one decision inconsistent instants. Translating provider failure into “unknown plan” hides an operational problem as a business refusal.

The factory earns its cost because it chooses policy using fallible external answers. If a caller already has the complete typed values, calling startTrial() directly is clearer. A factory which merely mirrors every constructor argument is an extra place to look without an extra decision to find.

Eloquent has a legitimate, different lifecycle

A strict required constructor on an Eloquent model creates a concrete conflict. Laravel 13’s pinned Model source constructs new instances without arguments inside newInstance(). newFromBuilder() uses that mechanism, assigns raw attributes, selects the connection, and marks the model as existing. Eloquent’s builder calls newFromBuilder() while hydrating query results.

The fixture runs that code rather than approximating it. A conventional SubscriptionRow extends Model hydrates all six stored attributes and uses the requested connection. A model subclass with an extra required constructor argument reaches Eloquent’s internal no-argument construction and fails with ArgumentCountError.

That does not make Eloquent defective. Its hydration contract is coherent for Active Record. It also does not mean a domain object must accept incomplete state so Eloquent can fill it later.

When the two contracts differ, keep both honest:

database row
    ↓ Eloquent hydration
SubscriptionRow
    ↓ SubscriptionMapper
Subscription::reconstitute(...)
complete domain state

The row remains compatible with the framework. The mapper converts storage scalars into value objects and calls the domain reconstitution path. In the fixture, a valid row returns the exact stored state with no creation fact. A row claiming active status with a trial end fails as Cannot reconstitute subscription row sub-100. and retains its raw attributes unchanged.

The mapper boundary is not free. There are two objects, translation code, and a failure category to maintain. If the Eloquent model is already the accepted domain model and its lifecycle supports the required rules, adding a shadow aggregate may only duplicate concepts. The split pays when framework hydration, historical interpretation, and domain construction genuinely need different contracts.

Long constructors are evidence, not a verdict

Moving every field into a constructor can expose an awkward parameter list. That list may reveal several different problems:

  • the object owns unrelated responsibilities;
  • primitive values are hiding useful domain types;
  • several creation policies have been collapsed into booleans and nulls;
  • a caller is performing collaboration which belongs in a factory; or
  • the state is complete but naturally has several required parts.

A builder is useful for large optional configuration or a real staged protocol. Its terminal build() should still establish usable state. Returning the same mutable domain object throughout the build only gives the invalid intermediate state a fluent interface.

Parameter count alone cannot choose among decomposition, value objects, named constructors, a factory, or a builder. Follow the decisions. Values needed by every operation belong at the state boundary. Pure policy alternatives may deserve names. External answers belong outside. Optional configuration may deserve a separate assembly object.

Thirteen defects test the boundaries

The fixture passes 15 tests with 42 assertions across construction, factory, and Eloquent hydration. Positive examples alone would still permit a weak suite, so the reproduction applies 13 defects independently.

They allow a zero-day trial, shorten the calculated trial, contradict status and trial end, omit or duplicate the creation fact, recalculate historical state, choose the wrong factory policy, read time twice, consume identity too early, collapse two failure categories, bypass stored status in the mapper, and require constructor input on the Eloquent row. Every defect fails its named expectation.

The structural check also refuses setters, ambient collaborators, a framework dependency in the domain object, a required constructor on the persistence row, or a second mapping edge. A semantic verifier checks the retained test totals, mutation provenance, diffs, source observations, and construction paths. It deliberately emits no quality score.

The evidence was reproduced from a clean archive of commit c560cd3f2d79b7bc007b180e3836c51014f59d71. The verifier, boundary analysis, normalized positive suite, and representative factory, mapper, and Eloquent diffs match the retained evidence by SHA-256. Only PHPUnit elapsed time is normalized.

This proves that the synthetic boundaries have the stated behavior. It does not prove that every domain needs a private constructor, separate factory, or mapper. It supplies failure-sensitive evidence for deciding which role is present.

Choose the boundary which owns the decision

Use one constructor when there is one complete input shape and the caller already has every value. Use named constructors when different pure policies or input forms deserve distinct names. Use an external factory when completing the object requires collaborators, I/O, runtime selection, or failure translation.

Keep Eloquent direct when its model is an adequate domain boundary. Add a separate row and mapper when strict construction or historical interpretation conflicts with framework hydration. Use a builder when staged assembly is itself useful, while keeping the unfinished object out of the domain API.

Then apply one final test: after any successful path returns, can every public method rely on the object’s state without first asking which fields the caller remembered to set?

If not, the construction decision is still leaking into every use of the object.