Fluent interface — method chaining that reads like a sentence
Concept
Fluent interface — a design pattern where method calls are chained together, reading almost like a sentence. Each method returns $this (or a new instance), enabling chains like $query->select('id')->where('active', true)->orderBy('name')->limit(10)->get().
Origin: Martin Fowler coined the term "fluent interface" in 2005. The idea predates that — it was common in Smalltalk.
What makes it fluent:
- Methods return
$this(for mutable builders) or a new object (for immutable builders). - Method names read like natural language.
- Each method modifies state or configuration and returns the object for the next call.
Mutable fluent interface: Methods modify $this and return it. Simpler to implement. Dangerous if the object is shared (two parts of code hold the same reference).
Immutable fluent interface: Each method returns a NEW instance with the change applied. $this is never modified. Safe for sharing. More memory allocation. PHP 8 readonly properties encourage this.
Common examples:
- Laravel Query Builder:
DB::table('users')->where(...)->orderBy(...)->get(). - Laravel Eloquent:
User::query()->where(...)->with(...)->paginate(). - Symfony Form builder, Swift Mailer.
- PHP's
DateTime→DateTimeImmutable(PHP 8.2+).
Anti-fluent (bad chaining): Returning the result of an operation instead of $this. $user->getName()->toUpper() — getName() returns a string, not a User. That's just method chaining on different objects, not a fluent interface.
When NOT to use fluent:
- When each method does something meaningful on its own (side effects).
$user->save()->notify()—save()should return the affected rows, not$this. - When you need to capture intermediate results.
Code Example
<?php
// MUTABLE fluent interface — methods return $this
class QueryBuilder
{
private string $table = '';
private array $selects = ['*'];
private array $wheres = [];
private ?string $orderBy = null;
private ?int $limit = null;
public function table(string $table): static
{
$this->table = $table;
return $this; // return $this for chaining
}
public function select(string ...$columns): static
{
$this->selects = $columns;
return $this;
}
public function where(string $column, mixed $value): static
{
$this->wheres[] = [$column, $value];
return $this;
}
public function orderBy(string $column, string $direction = 'ASC'): static
{
$this->orderBy = "{$column} {$direction}";
return $this;
}
public function limit(int $n): static { $this->limit = $n; return $this; }
public function toSql(): string
{
$select = implode(', ', $this->selects);
$sql = "SELECT {$select} FROM {$this->table}";
if ($this->wheres) {
$conditions = array_map(fn($w) => "{$w[0]} = ?", $this->wheres);
$sql .= " WHERE " . implode(' AND ', $conditions);
}
if ($this->orderBy) $sql .= " ORDER BY {$this->orderBy}";
if ($this->limit) $sql .= " LIMIT {$this->limit}";
return $sql;
}
}
$sql = (new QueryBuilder())
->table('users')
->select('id', 'name', 'email')
->where('active', 1)
->where('role', 'admin')
->orderBy('name', 'ASC')
->limit(10)
->toSql();
// SELECT id, name, email FROM users WHERE active = ? AND role = ? ORDER BY name ASC LIMIT 10
// IMMUTABLE fluent interface — each method returns a NEW instance
final class ImmutableQuery
{
private function __construct(
private readonly string $table = '',
private readonly array $wheres = [],
private readonly ?int $limit = null,
) {}
public static function from(string $table): self { return new self(table: $table); }
public function where(string $col, mixed $val): self
{
return new self($this->table, [...$this->wheres, [$col, $val]], $this->limit);
}
public function limit(int $n): self { return new self($this->table, $this->wheres, $n); }
}
$base = ImmutableQuery::from('orders')->where('status', 'pending');
$limited = $base->limit(10); // NEW instance — $base unchanged!
$all = $base; // still the un-limited query — safe to reuse