Design Patterns And Data Architecture

Builder Pattern

The builder pattern separates step-by-step construction from the object or result being built. In PHP applications, builders appear around complex query objects, HTTP requests, email messages, report definitions, export options, test fixtures, form configuration, and immutable objects with many optional settings. A builder is useful when direct construction becomes hard to read or easy to misuse.

A builder is not a replacement for every constructor. If an object has two required values and no branching rules, direct construction is clearer. A builder earns its place when construction has several optional pieces, a meaningful order, validation across fields, reusable presets, or fluent configuration that makes the caller's intent easier to read.

The pattern can also protect invariants. A report export might require a date range, format, and actor. It may optionally include filters, sorting, and a notification target. A builder can collect those decisions, validate required parts, and produce a final immutable object. That is better than passing a long array whose keys may be missing or misspelled.

What A Builder Owns

A builder owns the construction process. It does not normally own the business operation that uses the built object. ReportRequestBuilder can produce a ReportRequest; it should not run the report, write files, send email, and update billing unless the class name and contract clearly represent that complete workflow.

Good builders make invalid construction harder. They can enforce required values, normalize repeated options, prevent conflicting settings, and produce a final object only when the configuration is complete. The final object should usually be easier to reason about than the builder itself.

Builders are often mutable while they gather configuration. The object they produce is often immutable. This split is useful: flexible construction at the edge, stable value afterward.

A Small Builder Example

This example builds search filters. It is intentionally small, but it shows the role split: the builder collects choices and produces an immutable result.

PHP example
<?php

declare(strict_types=1);

final readonly class ProductSearch
{
    /** @param list<string> $tags */
    public function __construct(
        public string $query,
        public array $tags,
        public int $page,
    ) {
    }
}

final class ProductSearchBuilder
{
    private string $query = '';

    /** @var list<string> */
    private array $tags = [];

    private int $page = 1;

    public function query(string $query): self
    {
        $this->query = trim($query);

        return $this;
    }

    public function tag(string $tag): self
    {
        $tag = trim($tag);

        if ($tag !== '') {
            $this->tags[] = $tag;
        }

        return $this;
    }

    public function page(int $page): self
    {
        $this->page = max(1, $page);

        return $this;
    }

    public function build(): ProductSearch
    {
        return new ProductSearch($this->query, array_values(array_unique($this->tags)), $this->page);
    }
}

$search = (new ProductSearchBuilder())
    ->query(' notebooks ')
    ->tag('office')
    ->tag('office')
    ->page(2)
    ->build();

echo $search->query . ' page ' . $search->page . ' tags ' . implode(',', $search->tags) . PHP_EOL;

// Prints:
// notebooks page 2 tags office

The builder trims values, prevents page zero, and removes duplicate tags. The built ProductSearch object is readonly and safe to pass onward.

Fluent Interfaces

Many builders use a fluent interface, where methods return $this so calls can be chained. Fluent style can make configuration readable when each method name is clear. It can also hide too much if every method accepts loose arrays or ambiguous strings.

A fluent builder should still have a clear final method such as build(), toRequest(), or create(). That final method is where required-field validation and final normalization usually belong.

Avoid fluent builders that mutate shared global state. The builder should usually be a short-lived object created for one construction task. Reusing one mutable builder across requests, queue jobs, or tests can leak configuration from one use to another.

Builder Versus Factory

A factory chooses or creates an object, often in one call. A builder gathers construction details over several calls before producing the result. Use a factory when the caller already has all required input and construction should be hidden. Use a builder when the caller benefits from incremental, named configuration steps.

For example, PaymentGatewayFactory::forMerchant($merchant) is a factory. It chooses a provider from merchant configuration. EmailMessageBuilder can be a builder because the caller adds recipient, subject, body, attachments, headers, and tags before building the message.

Do not combine both roles casually. A BuilderFactoryManager is usually a sign that the construction boundary has not been named clearly.

Builder Versus Optional Constructor Arguments

PHP supports named arguments, defaults, and readonly properties. Those features reduce the need for builders in many cases. A constructor such as new PageRequest(page: 2, perPage: 50, sort: 'name') can be clearer than a builder when the object is small.

Use named arguments first when the object has a manageable number of fields and no complex construction lifecycle. Use a builder when optional fields become numerous, groups of fields interact, or construction would otherwise require several temporary variables and validation steps.

Builders should reduce confusion, not add ceremony. If the builder has the same arguments as the constructor and no extra rules, remove it.

Builders For Queries

Query builders are common in PHP frameworks and database libraries. They let code assemble SQL or query objects incrementally. A query builder can be useful because filters, sorting, joins, limits, and conditions vary by request.

Be careful with ownership. A low-level SQL query builder is not the same as an application query object. Controllers should not build complicated database queries by chaining dozens of low-level methods if that spreads persistence details. A repository or query object can use a builder internally while exposing a meaningful method such as ordersReadyForFulfilment().

Query builders also need parameter binding and allowlists. Do not concatenate raw sort fields, table names, or directions from user input. A builder can make safe assembly easier, but it does not make unsafe input safe automatically.

Builders For Tests

Test data builders are useful when tests need objects with sensible defaults and small overrides. Instead of repeating every required constructor argument in every test, a builder can create a valid user, order, or invoice and let the test change the one field that matters.

Test builders should keep intent visible. OrderBuilder::paid()->withTotal(1299)->build() tells a story. A builder with hidden defaults can also make tests misleading if nobody knows which state was created. Keep defaults realistic and local to tests.

Do not use production builders only because tests are awkward. If production object construction is too difficult, that may reveal a design issue. A test builder should help tests focus, not hide invalid production design.

Validation And Failure

A builder can validate at each step, at build(), or both. Validate early when a method receives a clearly invalid value, such as negative page size. Validate at build time when the rule depends on several fields, such as requiring either email or phone but not both.

Failure should be explicit. Throwing InvalidArgumentException can be enough for programmer errors. For user input, it may be better for a validator to collect field errors before the builder receives valid values.

Do not let build() silently invent missing required values. A report without a date range should not become "all time" unless that is the documented default.

Staged Builders And Required Order

Some builders need required steps in a specific order. For example, an export request may need an actor before permissions can be checked, then a report type before filters can be validated. A simple mutable builder can enforce this at build(), but a staged builder can make invalid order harder by returning a different interface after each required step.

Staged builders are useful for library APIs or high-risk construction, but they can be too much ceremony for ordinary application code. Use them when invalid order causes real bugs or security problems. For most PHP applications, a clear builder with required-field checks, focused tests, and good exception messages is enough.

Keep dependency boundaries visible. A builder for a value or request should not reach into the service container, database, or current HTTP request unless its name clearly identifies it as an adapter at that boundary. Hidden lookups make construction harder to test and can make the same builder behave differently in CLI, web, and worker contexts.

Common Failure Modes

One failure mode is overusing builders for simple objects. This makes code longer without improving safety.

Another failure mode is a mutable builder reused accidentally. Because builders often store state, reusing one instance can produce surprising results. Create a new builder for each construction or add a reset() only when lifecycle is carefully controlled.

A third failure mode is hiding validation too late. If a builder accepts invalid values throughout a long flow and fails only at the end with a vague message, users and developers get poor feedback.

A fourth failure mode is mixing construction with execution. A query builder should build a query or execute through a clearly named method; an email builder should build a message, not quietly send it during build().

Review Criteria

When reviewing a builder, ask what complexity it removes. Check whether named arguments or a small factory would be clearer. Check required fields, default values, validation timing, object mutability, and whether the final object is stable.

Check that builders used with user input do not bypass validation. Check that query builders bind values and allowlist structural SQL parts. Check that test builders keep defaults obvious.

What To Check

Before moving on, make sure you can:

  • explain builder as step-by-step construction
  • decide when named arguments or a factory are simpler
  • keep builders focused on construction rather than execution
  • validate required and conflicting settings deliberately
  • avoid reusable mutable builder state leaks
  • use query builders without exposing unsafe user-controlled SQL structure
  • use test data builders to improve test readability without hiding behavior

After this lesson, you should be able to look at a complex PHP constructor or configuration array and decide whether a builder would make construction safer, or whether it would only add ceremony.

Practice

Task: Design A Query Builder Boundary

Design a safe builder or query object boundary.

Requirements

Include allowed sort fields, page normalization, tag handling, and where SQL details should live.

Check your work

The controller should not concatenate raw SQL from query string values.

Show solution

Create a ProductSearchBuilder or ProductSearchInputFactory that receives parsed query values and produces a ProductSearch object. It should trim search text, normalize page to at least one, remove empty tags, and map sort fields through an allowlist such as name, price, and created_at.

Sort direction should also be allowlisted to asc or desc. Unknown values should fall back to a documented default or produce a validation error.

SQL details should live in a repository or query object that accepts ProductSearch. The controller should not concatenate column names or directions from request input.

Task: Review Builder Fit

Review whether a builder is useful.

Requirements

Discuss direct construction, named constructors, validation, and when a builder would become justified.

Check your work

The answer should not use a builder just to avoid new.

Show solution

A builder would become justified only if currency construction involved several optional settings, locale-specific formatting rules, provider metadata, or a multi-step process. For one required value, a builder adds ceremony without safety.

Task: Test Builder Output

A ReportRequestBuilder accepts date range, format, filters, sort, and notification email before building an immutable ReportRequest.

Plan tests for the builder.

Requirements

Include required fields, invalid date range, unsupported format, duplicate filters, default sort, and no accidental send/execution.

Check your work

The tests should prove construction rules, not report execution.

Show solution

Test that build() fails when required fields such as date range or format are missing. Test that an end date before the start date is rejected. Test unsupported formats with a clear error.

Test duplicate filters are normalized or rejected according to the documented rule. Test default sort is applied when no sort is provided. Test notification email validation if the builder owns that rule.

Finally, verify that build() only returns a ReportRequest; it should not run the report, write files, or send email. Execution belongs to a separate use case.