0

Fluent interface — method chaining that reads like a sentence

Beginner5 min read·eng-12-021
interviewlaravel-src

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 DateTimeDateTimeImmutable (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
<?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