- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You open a pull request and the first line of the diff is this:
$order = new Order(
$customer,
$items,
OrderStatus::Pending,
$now,
null,
null,
null,
);
You read it three times. What does it mean? Is this a new checkout? An order being rehydrated from the database? An order cloned from a quote? The constructor will not tell you. It takes seven arguments, two of them are null, and the name of the method is __construct, a name PHP picked, not the domain.
The constructor is the worst place in your codebase to express intent. It is anonymous by language design. Every path that creates an Order calls the same opaque new Order(...), crams whatever it has into a positional argument list, and the defaults eventually turn into nulls.
Six months in, nobody on the team can read the call site without ctrl-clicking into the class.
The fix is older than PHP 8 and well documented in the DDD literature, but it still surprises people every time. Make the constructor private. Add named factories. The class now has verbs.
What the positional constructor is hiding
A typical Order class accumulates fields the same way a kitchen drawer accumulates batteries. Each new feature adds one parameter:
final class Order
{
public function __construct(
public readonly OrderId $id,
public readonly CustomerId $customerId,
public readonly array $items,
public OrderStatus $status,
public readonly DateTimeImmutable $placedAt,
public ?QuoteId $sourceQuoteId = null,
public ?DateTimeImmutable $confirmedAt = null,
public ?DateTimeImmutable $cancelledAt = null,
) {}
}
Three problems compound. First, the constructor accepts states that should not coexist. An order with status = Pending and a non-null confirmedAt is a bug, but the constructor allows it. Second, every caller has to know which nulls to pass. The HTTP handler and the Doctrine hydrator and the unit test all call the same anonymous entry point, even though they are doing different things. Third, the name Order carries zero information about which lifecycle stage you are in.
The signature tells you how many fields exist. It tells you nothing about what an Order actually is.
Three things the same class actually does
Step back from the constructor and ask the domain. An Order is created in three distinct ways, and the differences matter:
-
A customer places a new order from a cart. Identity is generated. Status starts at
Pending.placedAtis now. NosourceQuoteId, noconfirmedAt, nocancelledAt. Invariants must run: the cart cannot be empty, the customer must exist, the total must be non-negative. - Doctrine reconstitutes an order from a row. Every field already exists. Nothing is computed. Nothing is validated — the row already passed validation when it was first written. The job is to put the bytes back in the right shape.
-
A customer places an order derived from a saved quote. Identity is generated. Status starts at
Pending.sourceQuoteIdis set. Items come from the quote, not the cart. A different invariant runs: the quote must not be expired.
These are three verbs: place, reconstitute, placeFromQuote. They are not three values for one constructor flag. Different inputs. Different invariants. Different outputs. The language already has a way to express that: methods with names.
The named-constructor pattern in PHP 8.3
Make __construct private. Add static factories. Each factory becomes a documented entry point with its own argument list and its own invariants.
<?php
declare(strict_types=1);
namespace App\Order\Domain;
use DateTimeImmutable;
final class Order
{
private function __construct(
public readonly OrderId $id,
public readonly CustomerId $customerId,
public readonly array $items,
public OrderStatus $status,
public readonly DateTimeImmutable $placedAt,
public readonly ?QuoteId $sourceQuoteId,
public ?DateTimeImmutable $confirmedAt,
public ?DateTimeImmutable $cancelledAt,
) {}
public static function place(
CustomerId $customerId,
array $items,
DateTimeImmutable $now,
): self {
if ($items === []) {
throw new EmptyCart();
}
foreach ($items as $item) {
if (!$item instanceof LineItem) {
throw new InvalidLineItem();
}
}
return new self(
id: OrderId::generate(),
customerId: $customerId,
items: $items,
status: OrderStatus::Pending,
placedAt: $now,
sourceQuoteId: null,
confirmedAt: null,
cancelledAt: null,
);
}
public static function placeFromQuote(
Quote $quote,
DateTimeImmutable $now,
): self {
if ($quote->isExpired($now)) {
throw new QuoteExpired($quote->id());
}
return new self(
id: OrderId::generate(),
customerId: $quote->customerId(),
items: $quote->items(),
status: OrderStatus::Pending,
placedAt: $now,
sourceQuoteId: $quote->id(),
confirmedAt: null,
cancelledAt: null,
);
}
public static function reconstitute(
OrderId $id,
CustomerId $customerId,
array $items,
OrderStatus $status,
DateTimeImmutable $placedAt,
?QuoteId $sourceQuoteId,
?DateTimeImmutable $confirmedAt,
?DateTimeImmutable $cancelledAt,
): self {
return new self(
id: $id,
customerId: $customerId,
items: $items,
status: $status,
placedAt: $placedAt,
sourceQuoteId: $sourceQuoteId,
confirmedAt: $confirmedAt,
cancelledAt: $cancelledAt,
);
}
}
Three things changed and one stayed the same.
__construct is private. No code outside the class can call new Order(...). That alone removes the worst version of the bug — the one where a caller passes a half-built order and gets back an invalid state.
place is the use-case path. It takes only what a use case has: a customer id, a cart of items, a clock. It runs domain invariants. It generates identity. It picks the starting status. Calls read Order::place($customerId, $items, $clock->now()) and you know exactly what is happening.
placeFromQuote is a sibling use case that shares the same target — a pending order — but starts from a different source. The invariants are different. The shape of the call site is different. Bundling these into one constructor would have required a third argument for "is this from a quote" and a fourth for the quote id, both nullable, both ignored half the time.
reconstitute is the persistence path. It takes everything, validates nothing, and trusts the caller. Only the ORM should be calling it. It exists because there is no other honest way to say "rebuild this object from a row without re-running creation invariants." Doctrine hydrating an order from orders should not re-check that the cart is non-empty. That check ran the day the order was placed. Re-running it on every fetch wastes CPU now and guarantees a bug the day the invariants change.
Why Doctrine needs reconstitute
The reconstitution problem is the one that bites teams using Doctrine ORM, Eloquent, or any other identity-map persistence layer. The ORM owns the object lifecycle on the way out of the database. It reads a row, builds an object, hands it back. If the only public constructor is place, the ORM cannot do its job. place generates a new id, sets placedAt to now, and refuses an empty cart. Three lies, told once per row.
Doctrine 3 reads private fields via reflection by default, and the default hydration mode does not call your constructor at all. It calls newInstanceWithoutConstructor() and writes fields directly. That mostly works, but it has two costs. The first is that any logic in your __construct is silently bypassed, which is fine for a private constructor that does nothing but assign, and a disaster for a public constructor that runs validation. The second is that your domain object now has two creation paths, only one of which is named, and the named one is invisible to anyone reading the class.
Adding reconstitute makes the second path explicit. The constructor becomes a private detail. Both place and reconstitute go through it for different reasons and with different expectations about validation.
Configuring Doctrine to use reconstitute requires a custom hydrator or a lifecycle approach, but the standard pattern is to point Doctrine at the private fields and let it bypass the constructor entirely, then surface reconstitute as the public, documented entry point that describes what Doctrine is doing under the hood. The factory becomes a piece of documentation that the type system enforces: anyone reading the domain class sees three named paths and understands that one of them is for the ORM.
If you want stricter control (you want the domain class to refuse direct field writes from reflection), you write a Doctrine custom hydration mode that calls Order::reconstitute(...) from the row data. That is a longer post. The named factory is the precondition for either approach.
How the call sites change
Before, every entry point looked the same and meant something different:
$order = new Order($customer, $items, OrderStatus::Pending, $now, null, null, null);
$order = new Order(
$row['customer'],
$row['items'],
OrderStatus::from($row['status']),
$row['placed_at'],
$row['source_quote_id'],
$row['confirmed_at'],
$row['cancelled_at'],
);
After, the call sites read like the domain reads:
$order = Order::place($customer->id(), $cart->items(), $clock->now());
$order = Order::placeFromQuote($quote, $clock->now());
$order = Order::reconstitute(
id: $id,
customerId: $customerId,
items: $items,
status: $status,
placedAt: $placedAt,
sourceQuoteId: $sourceQuoteId,
confirmedAt: $confirmedAt,
cancelledAt: $cancelledAt,
);
The first two are short because they are the use-case paths and they take only what a use case needs. The third is long because reconstitution is supposed to look unusual. If you are typing all eight arguments by hand in a controller, the length of the call is the warning sign that you are doing something the ORM should be doing for you.
Tests get easier in a way that is hard to notice until you do it
The hidden win shows up in fixtures. A test that needs an order in a specific state used to look like this:
$order = new Order(
new OrderId('test-1'),
new CustomerId('c-1'),
[new LineItem('SKU', 1, 1000)],
OrderStatus::Confirmed,
new DateTimeImmutable('2026-01-01'),
null,
new DateTimeImmutable('2026-01-02'),
null,
);
A reader of that test has to mentally map positional arguments to fields and check which nulls mean what. With reconstitute, the test reads as a row:
$order = Order::reconstitute(
id: new OrderId('test-1'),
customerId: new CustomerId('c-1'),
items: [new LineItem('SKU', 1, 1000)],
status: OrderStatus::Confirmed,
placedAt: new DateTimeImmutable('2026-01-01'),
sourceQuoteId: null,
confirmedAt: new DateTimeImmutable('2026-01-02'),
cancelledAt: null,
);
Same fields, but the named arguments and the verb on the method tell you what kind of fixture this is. A reconstitute fixture is "an order that exists in the database in this state." A place fixture is "an order that just got placed." The test name and the factory name agree.
A small rule for choosing factory names
Pick names that match the domain verbs, not the technical operation. Order::create() is a worse name than Order::place(), because create is what the language does and place is what the customer does. Same for Order::fromArray() versus Order::reconstitute(): one names the input shape, the other names the intent.
If a factory name does not survive being read aloud to a non-engineer ("the customer places an order, the system reconstitutes an order from storage, the customer places an order from a quote"), try again. The names exist to make call sites readable years after the code is written, by people who never met the original author.
A class with three or four well-named factories and a private constructor is one of the cheapest wins in a PHP domain layer. It pays back the first time someone reads a call site and knows exactly what is happening without opening the class.
If this was useful
This is one pattern from a longer pass on what a PHP domain layer looks like when Doctrine is an adapter and use cases own the verbs. Decoupled PHP walks the named-factory pattern through the rest of the domain: value objects, transitions like confirm() and cancel(reason), the boundary between the use case and the ORM, and how to keep the same shape across Laravel and Symfony.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)