Fluent interfaces and method chaining — building an API
Concept
A fluent interface is an API design pattern where each method returns the object itself (or a modified copy of it), allowing method calls to be chained in a single expression. The term was coined by Martin Fowler and Eric Evans in 2005 and has been central to PHP frameworks ever since. Laravel's query builder, Eloquent, Mail, HTTP client, validation, and collection APIs are all fluent interfaces.
The implementation is simple: each method ends with return $this (for mutable builders) or return new static(...) (for immutable builders). The design challenge is knowing which methods should return $this and which should "terminate" the chain by returning a concrete result. Convention names terminating methods actions: get(), execute(), send(), first(), count(), toArray().
Mutable vs immutable fluent interfaces have different characteristics. A mutable builder (return $this) is simpler to implement and allocates no extra objects, but sharing a builder between two code paths can cause subtle state leaks. An immutable builder (return new static(...) or return clone $this with modification) is safer — each branch of code that shares a base builder gets its own independent copy — but it allocates more objects. Laravel's query builder uses mutating chaining; its HTTP pending request builder uses immutable chaining via cloning.
The static return type is critical. If you return self, a subclass that overrides where() will have its method typed as returning the parent Builder, losing the subclass's extra methods. Returning static preserves the actual runtime type, so IDEs and static analysers can correctly type the chain through subclass methods. This is exactly how Eloquent's per-model query builders work — each model gets a builder subclass with model-specific scopes, and static ensures those scopes remain visible throughout a chain.
Named arguments (PHP 8.0) pair extremely well with fluent interfaces, particularly when constructing configuration objects where many parameters are optional. Instead of positional arguments that force you to remember which position maps to which setting, named arguments make each option self-documenting: ->cache(key: 'users', ttl: 300, tags: ['users']).
Code Example
<?php
declare(strict_types=1);
// ── Immutable fluent Query Builder ────────────────────────────────────────
final class QueryBuilder
{
private function __construct(
private readonly string $table,
private readonly array $conditions = [],
private readonly array $columns = ['*'],
private readonly ?int $limitVal = null,
private readonly ?int $offsetVal = null,
private readonly array $orderBys = [],
) {}
public static function table(string $table): static
{
return new static($table);
}
public function select(string ...$columns): static
{
return new static(
$this->table, $this->conditions,
$columns, $this->limitVal, $this->offsetVal, $this->orderBys,
);
}
public function where(string $column, mixed $value, string $op = '='): static
{
$conditions = [...$this->conditions, compact('column', 'op', 'value')];
return new static(
$this->table, $conditions,
$this->columns, $this->limitVal, $this->offsetVal, $this->orderBys,
);
}
public function orderBy(string $column, string $direction = 'ASC'): static
{
$orderBys = [...$this->orderBys, "{$column} {$direction}"];
return new static(
$this->table, $this->conditions,
$this->columns, $this->limitVal, $this->offsetVal, $orderBys,
);
}
public function limit(int $n): static
{
return new static(
$this->table, $this->conditions,
$this->columns, $n, $this->offsetVal, $this->orderBys,
);
}
public function offset(int $n): static
{
return new static(
$this->table, $this->conditions,
$this->columns, $this->limitVal, $n, $this->orderBys,
);
}
// Terminator — returns a string (the result), not $this
public function toSql(): string
{
$cols = implode(', ', $this->columns);
$sql = "SELECT {$cols} FROM {$this->table}";
if ($this->conditions) {
$parts = array_map(
fn($c) => "{$c['column']} {$c['op']} " . var_export($c['value'], true),
$this->conditions
);
$sql .= " WHERE " . implode(' AND ', $parts);
}
if ($this->orderBys) {
$sql .= " ORDER BY " . implode(', ', $this->orderBys);
}
if ($this->limitVal !== null) {
$sql .= " LIMIT {$this->limitVal}";
}
if ($this->offsetVal !== null) {
$sql .= " OFFSET {$this->offsetVal}";
}
return $sql;
}
}
// ── Usage ──────────────────────────────────────────────────────────────────
$base = QueryBuilder::table('users')
->select('id', 'email', 'name')
->where('active', true);
// Branching is safe because the builder is immutable
$admins = $base->where('role', 'admin')->orderBy('name')->limit(10);
$moderators = $base->where('role', 'moderator')->orderBy('created_at', 'DESC');
echo $admins->toSql() . "\n";
// SELECT id, email, name FROM users WHERE active = true AND role = 'admin' ORDER BY name ASC LIMIT 10
echo $moderators->toSql() . "\n";
// SELECT id, email, name FROM users WHERE active = true AND role = 'moderator' ORDER BY created_at DESC
// ── Email builder with named arguments (PHP 8.0) ──────────────────────────
final class MailMessage
{
private string $subject = '';
private string $body = '';
private array $to = [];
private ?string $replyTo = null;
public function subject(string $subject): static
{
$clone = clone $this;
$clone->subject = $subject;
return $clone;
}
public function to(string ...$addresses): static
{
$clone = clone $this;
$clone->to = $addresses;
return $clone;
}
public function body(string $body): static
{
$clone = clone $this;
$clone->body = $body;
return $clone;
}
public function replyTo(string $address): static
{
$clone = clone $this;
$clone->replyTo = $address;
return $clone;
}
public function send(): bool
{
echo "Sending '{$this->subject}' to " . implode(', ', $this->to) . "\n";
return true; // pretend send
}
}
(new MailMessage())
->to('alice@example.com', 'bob@example.com')
->subject(subject: 'Welcome!')
->body(body: 'Hello from the fluent interface.')
->replyTo(address: 'support@example.com')
->send();Interview Q&A
Q: What is the difference between a fluent interface and method chaining, and why does the distinction matter?
Method chaining is any pattern where multiple methods are called on the same expression: $str->trim()->upper(). A fluent interface is a specific design where the chaining is intentional, the API is designed to be read like a sentence, and every non-terminating method returns a chainable object. The distinction matters because bad chaining (returning $this from methods that should return a result) violates the Command-Query Separation (CQS) principle: a method should either command (modify state, return void) or query (return data without side effects), not both. A fluent interface resolves this by treating the builder itself as an intermediate query object — you are building a description of an operation, not performing it. The final terminator (get(), send(), execute()) performs the command and returns a non-builder result.
Q: How does Laravel's query builder handle the static return type to preserve subclass query scopes through a chain?
Laravel's Illuminate\Database\Query\Builder uses $this mutation and returns $this (typed as static in docblocks and as static in actual return types since PHP 8.0). When Eloquent creates a per-model builder (e.g., User::query()), it returns an Illuminate\Database\Eloquent\Builder subclass that has been given the model instance. Scopes defined on the User model (scopeActive(), scopeVerified()) are registered on this builder subclass. Because where() and every other method returns static, the typed result remains the Eloquent builder subclass throughout the chain, meaning User::query()->active()->verified()->limit(10)->get() correctly resolves active() and verified() as scope calls rather than causing "method not found" errors.
Q: When is an immutable fluent builder worth the extra allocation cost over a mutable one?
Immutable builders are justified when the builder instance is reused across branches or stored as a reusable base configuration. Consider Laravel's HTTP client: Http::withHeaders(['X-App' => '1'])->withToken($token) creates a pending request that you might use for multiple independent requests — if it were mutable, sending request A could leave state that contaminates request B. The allocation cost of clone (or new static(...)) for PHP objects is measured in microseconds and is negligible compared to a network call or database query that the builder typically describes. The cost becomes meaningful only in tight loops constructing thousands of builders per second — which is rarely the use case.