Builder — constructing complex objects step by step
Concept
The Builder pattern separates the construction of a complex object from its representation, allowing the same construction process to produce different representations. Instead of a constructor with ten parameters — some required, some optional, some only valid in certain combinations — a Builder provides a fluent API that assembles the object step by step, then produces it with a terminal build() call.
The problem it solves is telescoping constructors and invalid intermediate states. When an object like an Invoice requires a customer, line items, a billing address, optional tax settings, optional discounts, a payment term, and a currency, you face two bad choices: a constructor with ten arguments (caller must remember position and type of each), or an anemic object where setters can be called in any order and the object is always in a semi-valid state. Builder eliminates both by making the construction process explicit and validating completeness only at build() time.
UML structure: A Builder interface or abstract class declares all the steps (setCustomer(), addLineItem(), setBillingAddress(), etc.). Concrete builders implement these steps and accumulate state. A Director class (optional) defines the order in which to call the steps for common configurations. The build() method validates the accumulated state and returns the fully constructed product.
When to use: constructing complex objects with many optional or dependent fields; when you need multiple representations of the same object (a simple invoice vs. a detailed invoice vs. a proforma); when construction logic should be isolated from business logic. When NOT to use: for simple objects with 2–3 fields — a constructor or static named constructor is far simpler. Over-engineering simple objects with a Builder adds boilerplate with no benefit.
Laravel uses Builder extensively. Illuminate\Mail\Mailable is assembled step by step (->to(), ->subject(), ->view(), ->attach()). Illuminate\Database\Query\Builder is the canonical example — the entire query is assembled with fluent calls, and get() / first() / toSql() are the terminal operations that actually produce something. Illuminate\Http\Client\PendingRequest (the HTTP client) builds the request configuration before send() fires it.
| Pattern | Object State During Construction |
|---|---|
| Constructor injection | Fully valid from instantiation, or throws immediately |
| Setter injection (anemic) | Invalid/partial state until all setters called — dangerous |
| Builder | Mutable during build, immutable final product — safe |
| Named constructors | Simple objects only; cannot handle many optional fields |
Code Example
<?php
declare(strict_types=1);
// ── Product — immutable once built ─────────────────────────────────────────
final class Invoice
{
private function __construct(
public readonly string $invoiceNumber,
public readonly Customer $customer,
public readonly BillingAddress $billingAddress,
/** @var LineItem[] */
public readonly array $lineItems,
public readonly string $currency,
public readonly int $paymentTermDays,
public readonly ?Discount $discount,
public readonly ?TaxSettings $taxSettings,
public readonly \DateTimeImmutable $issuedAt,
public readonly \DateTimeImmutable $dueAt,
) {}
// Only the Builder may call the constructor
public static function fromBuilder(InvoiceBuilder $builder): self
{
return new self(
invoiceNumber: $builder->invoiceNumber,
customer: $builder->customer,
billingAddress: $builder->billingAddress,
lineItems: $builder->lineItems,
currency: $builder->currency,
paymentTermDays: $builder->paymentTermDays,
discount: $builder->discount,
taxSettings: $builder->taxSettings,
issuedAt: $builder->issuedAt ?? new \DateTimeImmutable(),
dueAt: $builder->dueAt ?? new \DateTimeImmutable("+{$builder->paymentTermDays} days"),
);
}
public function totalCents(): int
{
$subtotal = array_sum(array_map(fn(LineItem $l) => $l->totalCents(), $this->lineItems));
$afterDiscount = $this->discount ? $this->discount->apply($subtotal) : $subtotal;
return $this->taxSettings ? $this->taxSettings->apply($afterDiscount) : $afterDiscount;
}
}
// ── Builder ─────────────────────────────────────────────────────────────────
final class InvoiceBuilder
{
// Package-private fields — only Invoice::fromBuilder reads them
public string $invoiceNumber;
public Customer $customer;
public BillingAddress $billingAddress;
public array $lineItems = [];
public string $currency = 'USD';
public int $paymentTermDays = 30;
public ?Discount $discount = null;
public ?TaxSettings $taxSettings = null;
public ?\DateTimeImmutable $issuedAt = null;
public ?\DateTimeImmutable $dueAt = null;
private function __construct() {}
public static function new(): self
{
$builder = new self();
$builder->invoiceNumber = 'INV-' . strtoupper(uniqid());
return $builder;
}
public function forCustomer(Customer $customer): self
{
$this->customer = $customer;
return $this;
}
public function billedTo(BillingAddress $address): self
{
$this->billingAddress = $address;
return $this;
}
public function addLineItem(string $description, int $unitCents, int $quantity = 1): self
{
$this->lineItems[] = new LineItem($description, $unitCents, $quantity);
return $this;
}
public function inCurrency(string $currency): self
{
$this->currency = strtoupper($currency);
return $this;
}
public function withPaymentTerms(int $days): self
{
$this->paymentTermDays = $days;
return $this;
}
public function withDiscount(Discount $discount): self
{
$this->discount = $discount;
return $this;
}
public function withTax(TaxSettings $taxSettings): self
{
$this->taxSettings = $taxSettings;
return $this;
}
public function issuedAt(\DateTimeImmutable $date): self
{
$this->issuedAt = $date;
return $this;
}
public function build(): Invoice
{
// Validate completeness at build time — not during accumulation
if (! isset($this->customer)) {
throw new \LogicException('Invoice must have a customer');
}
if (! isset($this->billingAddress)) {
throw new \LogicException('Invoice must have a billing address');
}
if (empty($this->lineItems)) {
throw new \LogicException('Invoice must have at least one line item');
}
return Invoice::fromBuilder($this);
}
}
// ── Optional Director — defines standard construction sequences ─────────────
final class InvoiceDirector
{
public function buildStandardInvoice(
Customer $customer,
BillingAddress $address,
array $items,
): Invoice {
$builder = InvoiceBuilder::new()
->forCustomer($customer)
->billedTo($address)
->withPaymentTerms(30);
foreach ($items as [$description, $unitCents, $qty]) {
$builder->addLineItem($description, $unitCents, $qty);
}
return $builder->build();
}
public function buildProformaInvoice(Customer $customer, BillingAddress $address): Invoice
{
return InvoiceBuilder::new()
->forCustomer($customer)
->billedTo($address)
->withPaymentTerms(0)
->addLineItem('Estimated project cost', 500_000, 1)
->build();
}
}
// ── Usage ───────────────────────────────────────────────────────────────────
$invoice = InvoiceBuilder::new()
->forCustomer($customer)
->billedTo($customer->primaryAddress())
->addLineItem('Laravel consulting — 10h', 15_000, 10)
->addLineItem('Code review', 8_000, 3)
->withDiscount(Discount::percentage(10))
->withTax(TaxSettings::vat(20))
->inCurrency('EUR')
->withPaymentTerms(14)
->build();Interview Q&A
Q: When does the Builder pattern make more sense than named static constructors?
Named static constructors (e.g., Invoice::forCustomer($customer, $address, $items)) are excellent for objects that have a small, fixed set of required fields. They become unwieldy when objects have more than 4–5 fields or when optional fields create many valid combinations. A ten-parameter static constructor is just a worse version of a ten-parameter constructor — callers still must know the order and meaning of every argument. Builder shines when the object has many optional fields, when fields have dependencies on each other (tax requires currency; discount requires at least one line item), or when you need multiple build paths (standard invoice vs. proforma). The validation happening at build() rather than at setter time also lets you give callers a clear error message about what is missing rather than a cryptic type error.
Q: How does Laravel's Query Builder relate to the Builder pattern?
Illuminate\Database\Query\Builder is a classic Builder. Methods like ->where(), ->join(), ->orderBy(), ->limit(), and ->select() each return $this, accumulating state. Terminal methods (get(), first(), count(), paginate(), toSql()) trigger the actual query compilation and execution. The Builder never sends a query until a terminal method is called — this is important for performance (chaining conditions in a loop does not execute N queries) and for composability (passing a partially-built query between methods). Illuminate\Database\Eloquent\Builder wraps Query\Builder and adds model-awareness. Illuminate\Http\Client\PendingRequest is another example: Http::withHeaders()->withToken()->timeout()->get() — everything before get() is builder step accumulation.
Q: What are the tradeoffs between a mutable builder and a copy-on-write immutable builder?
A mutable builder (the common implementation) is simple but has a subtle bug: if you call build() and then continue modifying the builder, the built object is no longer truly isolated. In PHP this is mitigated by the builder's build() method copying all fields into the immutable product. A copy-on-write approach (each builder method returns a new builder clone rather than $this) makes the builder itself immutable — every chain step produces a new builder. This allows safe branching: $base = InvoiceBuilder::new()->forCustomer($c); $standard = $base->withPaymentTerms(30)->build(); $rush = $base->withPaymentTerms(7)->build();. Both $standard and $rush start from $base without interference. The downside is higher object creation cost and more complex PHP clone semantics for nested mutable objects.