The builder pattern assembles a complex object step by step, through a series of small, readable method calls, instead of one giant constructor. It's the cure for a constructor with a dozen arguments that nobody can read.
What is the builder pattern?
The builder pattern splits "describe the object" from "produce the object." You call named
steps in any order, set only the parts you care about, then finish with one build() call.
The payoff is at the call site: instead of decoding a row of positional arguments, you read
a short sentence that says exactly what the object is.
The problem it solves
Some objects have many parts, and most of them are optional. Try to build one through a constructor and you get this:
$query = new SqlQuery('users', ['id', 'name'], 'active = 1', 'name ASC', 10, null);
What does null mean? Which argument is the limit? Swap two by accident and you've got a
silent bug. Adding a new option means touching every call. This is a classic
code smell - a long
parameter list.
The builder version
A builder gives each step a name and returns $this, so calls chain into a sentence-like
"fluent" API:
final class QueryBuilder
{
private array $columns = ['*'];
private ?string $where = null;
private ?string $orderBy = null;
private ?int $limit = null;
public function __construct(private string $table) {}
public function select(string ...$columns): static
{
$this->columns = $columns;
return $this;
}
public function where(string $condition): static
{
$this->where = $condition;
return $this;
}
public function orderBy(string $column): static
{
$this->orderBy = $column;
return $this;
}
public function limit(int $limit): static
{
$this->limit = $limit;
return $this;
}
public function build(): string
{
$sql = 'SELECT ' . implode(', ', $this->columns) . ' FROM ' . $this->table;
if ($this->where) { $sql .= ' WHERE ' . $this->where; }
if ($this->orderBy) { $sql .= ' ORDER BY ' . $this->orderBy; }
if ($this->limit) { $sql .= ' LIMIT ' . $this->limit; }
return $sql;
}
}
Now building the query reads clearly, and you only set what you need:
$sql = (new QueryBuilder('users'))
->select('id', 'name')
->where('active = 1')
->orderBy('name')
->limit(10)
->build();
Why this is better
Every step is named, so the call site is self-documenting. Optional parts are simply left
out - no null placeholders. Each method returns static (the current class), which is
what makes the chain work and keeps it correct in subclasses. The final build() produces
the finished object once everything is set.
Common mistake
Don't build a builder for a simple object. A class with two or three clear parameters
reads perfectly well with a normal constructor - a builder there is extra code for no gain.
Also, remember to return $this (as static) from every step; forget it once and the
chain breaks with a confusing error.
Worth knowing before you write one: modern PHP has softened the case for builders. Named
arguments (new SqlQuery(table: 'users', limit: 10)) already kill the "which position is
the limit?" problem for plain value objects, no builder needed. The builder still earns its
place when construction has real logic - validation between steps, accumulating a list,
producing different final types - not merely to label a handful of parameters.
When to use it
Use a builder when an object has many parts, especially optional ones, when the order of steps matters, or when the same steps could build different final results. If you've ever written a constructor with more than a handful of arguments, a builder is worth a look.
FAQ
Why return static instead of self?
static refers to the actual class at runtime, so if someone extends your builder, the
chain keeps returning the subclass. self would lock it to the parent class. For a final
builder it makes no difference, but static is the safer habit.
Is this the same as Laravel's query builder?
The idea is the same - Laravel's DB::table('users')->where(...)->get() is a builder-style
fluent API. You already use the pattern; this lesson just names it. The
patterns you already use in Laravel
lesson collects more of these.
Does the builder have to return a different object from build()?
Not necessarily. Sometimes build() returns a separate product object; sometimes, as with
a query string, it returns the assembled result directly. Both are valid uses of the
pattern.