Method Chaining in PHP — The Complete Developer’s Guide

1508 views
Method Chaining in PHP — The Complete Developer's Guide

When writing PHP applications, clean and readable code is just as important as performance. One powerful design pattern that makes code elegant is method chaining. This pattern is widely used in frameworks like Laravel, CodeIgniter, Symfony, and even in modern libraries.

In this article, we’ll explore what method chaining is, how it works in PHP, and why you should use it.

What is Method Chaining?

Method chaining is a technique where you call multiple methods on the same object in a single line. Each method returns the object itself ($this), allowing another method to be called immediately.

Instead of:

$user = new User();
$user->setName("John");
$user->setAge(25);
$user->setCountry("India");

You can write:

$user = (new User())->setName("John")->setAge(25)->setCountry("India");

This approach is cleaner and more readable.

How Does It Work?

The secret lies in returning $this (the current object) from every method you want to chain.

Example 1: Simple Method Chaining

class Person {
    private $name;
    private $age;
    private $country;

    public function setName($name) {
        $this->name = $name;
        return $this; // return current object
    }

    public function setAge($age) {
        $this->age = $age;
        return $this;
    }

    public function setCountry($country) {
        $this->country = $country;
        return $this;
    }

    public function getDetails() {
        return "Name: {$this->name}, Age: {$this->age}, Country: {$this->country}";
    }
}

// Usage
echo (new Person())->setName("John")->setAge(25)->setCountry("India")->getDetails();

Output:

Name: John, Age: 25, Country: India

Example 2: Query Builder Style

This is how frameworks like Laravel implement method chaining for database queries.

class QueryBuilder {
    private $table;
    private $columns = "*";
    private $conditions = "";

    public function table($table) {
        $this->table = $table;
        return $this;
    }

    public function select($columns) {
        $this->columns = $columns;
        return $this;
    }

    public function where($condition) {
        $this->conditions = "WHERE " . $condition;
        return $this;
    }

    public function get() {
        return "SELECT {$this->columns} FROM {$this->table} {$this->conditions}";
    }
}

// Usage
$sql = (new QueryBuilder())
        ->table("users")
        ->select("id, name, email")
        ->where("id = 5")
        ->get();

echo $sql;

Output:

SELECT id, name, email FROM users WHERE id = 5

Static Method Chaining

Static method chaining is also possible by returning an instance of the class inside a static method.

class Calculator {
    private $value;

    public function __construct($value = 0) {
        $this->value = $value;
    }

    public static function start($value = 0) {
        return new static($value);
    }

    public function add($num) {
        $this->value += $num;
        return $this;
    }

    public function get() {
        return $this->value;
    }
}

// Usage
echo Calculator::start(10)->add(5)->get(); // Output: 15

Advantages of Method Chaining

  1. Cleaner Code – Removes repetition of the object variable.
  2. Readability – Looks like natural language.
  3. Fluent Interface – Lets you design APIs that are intuitive.

Things to Watch Out For

  • Debugging is harder if one method in the chain fails.
  • Not all methods should return $this (e.g., a get() method usually returns data instead).
  • Overusing chaining may make code harder to maintain if logic is too complex.

Real-World Usage

If you’ve ever written Laravel code, you’ve already used method chaining:

$users = User::where("status", "active")
->orderBy("name")
->get();

Here:

  • where() and orderBy() return the query builder object.
  • get() finally executes the query and returns the result.

Method chaining is a simple but powerful design pattern in PHP. It makes your code more concise, readable, and elegant. Whether you’re designing a class for configuration, a query builder, or even a calculator, method chaining will give your code a fluent interface style.

If you’re working with frameworks like Laravel, CodeIgniter, or Symfony, understanding method chaining will make your development experience much smoother.

Method-Chaining-in-PHP

Why Method Chaining Is More Than Syntactic Sugar

Method chaining is one of those PHP patterns that looks simple on the surface — “just return $this” — but has an entire architecture hiding beneath it. The original tutorial’s three examples show the mechanics. This guide shows the craft.

Understanding method chaining at depth means understanding:

  • When to chain and when not to — there are real cases where it makes code worse
  • Immutable chains vs mutable chains — a critical distinction that affects every framework design decision
  • The Fluent Interface design pattern and how it differs from casual chaining
  • Conditional chaining (when(), unless(), tap()) — how Laravel makes complex queries readable
  • Error propagation through chains — how to handle failures gracefully without breaking flow
  • PHP 8 features that change how chains interact with nullable types
  • Testing code that uses chains — and why it matters
  • Real builders: SQL, HTML, HTTP request, email, validation — complete, production-grade implementations

 

By the end of this guide, you’ll know not just how to chain methods, but when to design classes that invite chaining, when to use immutable patterns, when named arguments are better, and how the builders in your favourite PHP frameworks actually work.

 

Part 1 — The Mechanics: How return $this Works

The Fundamental Pattern

Every method chain is built on one rule: methods that participate in the chain return the object they belong to.

<?php
class User {
    private string $name    = '';
    private int    $age     = 0;
    private string $email   = '';
    private string $country = '';

    public function setName(string $name): static {
        $this->name = $name;
        return $this;    // ← This is the entire secret
    }

    public function setAge(int $age): static {
        $this->age = $age;
        return $this;
    }

    public function setEmail(string $email): static {
        $this->email = $email;
        return $this;
    }

    public function setCountry(string $country): static {
        $this->country = $country;
        return $this;
    }

    public function build(): array {
        return [
            'name'    => $this->name,
            'age'     => $this->age,
            'email'   => $this->email,
            'country' => $this->country,
        ];
    }
}

// ── Without chaining: ────────────────────────────────────────────────────────
$user = new User();
$user->setName('Alice');
$user->setAge(28);
$user->setEmail('alice@example.com');
$user->setCountry('India');
$data = $user->build();

// ── With chaining: ────────────────────────────────────────────────────────────
$data = (new User())
    ->setName('Alice')
    ->setAge(28)
    ->setEmail('alice@example.com')
    ->setCountry('India')
    ->build();

 

Why static Instead of self in Return Types

The original article uses no return type declarations at all. In modern PHP 8.x, the correct return type for chainable methods is static, not self:

<?php
class Base {
    // ❌ Using 'self' — breaks inheritance
    public function withSelf(): self {
        return $this;
    }

    // ✅ Using 'static' — correct for chaining with inheritance
    public function withStatic(): static {
        return $this;
    }
}

class Child extends Base {
    // 'withSelf()' returns Base, breaking the chain's type
    // 'withStatic()' returns Child, maintaining the correct type through the chain
}

$child = new Child();

// ❌ Type error: withSelf() returns Base, not Child
// $child->withSelf()->childOnlyMethod(); // IDE error + potential type error

// ✅ Works correctly: withStatic() returns Child
$child->withStatic(); // Returns Child instance — correct

 

What Happens Internally (Step by Step)

// This chain:
$result = (new QueryBuilder())
    ->table('users')        // Step 1
    ->where('id', '>', 5)   // Step 2
    ->orderBy('name')       // Step 3
    ->limit(10)             // Step 4
    ->get();                // Step 5 — terminal method, returns data

// Is exactly equivalent to:
$qb  = new QueryBuilder();  // Create instance

$qb1 = $qb->table('users');         // Returns $this ($qb) → $qb1 === $qb
$qb2 = $qb1->where('id', '>', 5);   // Returns $this ($qb1) → $qb2 === $qb1
$qb3 = $qb2->orderBy('name');       // Returns $this ($qb2) → $qb3 === $qb2
$qb4 = $qb3->limit(10);             // Returns $this ($qb3) → $qb4 === $qb3
$result = $qb4->get();              // Terminal: returns actual data (not $this)

// In mutable chaining: $qb === $qb1 === $qb2 === $qb3 === $qb4
// (All the same object, modified in-place)

 

Part 2 — Mutable vs Immutable Method Chaining

This is the most important distinction the original article doesn’t mention. There are two fundamentally different ways to implement method chaining, and choosing the wrong one causes real bugs.

Mutable Chaining (The Default — Returns $this)

<?php
class MutableBuilder {
    private array $filters = [];

    public function where(string $column, string $value): static {
        $this->filters[] = [$column, $value];
        return $this;  // Returns THE SAME object, modified
    }

    public function getFilters(): array {
        return $this->filters;
    }
}

$builder = new MutableBuilder();

$queryA = $builder->where('status', 'active');
$queryB = $builder->where('role', 'admin');

// ⚠️ HIDDEN BUG: $queryA, $queryB, and $builder are ALL THE SAME OBJECT
// Both "queries" actually have BOTH filters!
var_dump($queryA === $builder);  // bool(true) — same reference!
var_dump($queryA->getFilters()); // [['status', 'active'], ['role', 'admin']]
var_dump($queryB->getFilters()); // [['status', 'active'], ['role', 'admin']]

// This means you CANNOT safely reuse a mutable builder:
$base = (new MutableBuilder())->where('status', 'active');

// These both modify the SAME object:
$admins  = $base->where('role', 'admin');   // Mutates $base!
$editors = $base->where('role', 'editor');  // Mutates $base again!

// Both have all three conditions — not what you intended

 

Immutable Chaining (Returns a New Clone)

<?php
class ImmutableBuilder {
    private array $filters = [];

    // Private constructor — force use of static factory method
    private function __construct() {}

    public static function create(): static {
        return new static();
    }

    public function where(string $column, string $value): static {
        $clone = clone $this;         // ← Create a new object
        $clone->filters[] = [$column, $value];
        return $clone;                // ← Return the clone, not $this
    }

    public function getFilters(): array {
        return $this->filters;
    }
}

$base = ImmutableBuilder::create()->where('status', 'active');

// These create separate objects — no shared state:
$admins  = $base->where('role', 'admin');
$editors = $base->where('role', 'editor');

var_dump($admins  === $base);    // bool(false) — different objects
var_dump($editors === $base);    // bool(false) — different objects

var_dump($base->getFilters());    // [['status', 'active']]        ← Clean
var_dump($admins->getFilters());  // [['status', 'active'], ['role', 'admin']]
var_dump($editors->getFilters()); // [['status', 'active'], ['role', 'editor']]

Deep Clone for Complex Objects

<?php
class RequestBuilder {
    private array   $headers  = [];
    private array   $options  = [];
    private ?object $body     = null;

    public function withHeader(string $name, string $value): static {
        $clone = clone $this;
        $clone->headers[$name] = $value;
        return $clone;
    }

    public function withBody(object $body): static {
        $clone = clone $this;
        // Deep clone the body object too — shallow clone would share the reference
        $clone->body = clone $body;
        return $clone;
    }

    /**
     * PHP's __clone() is called automatically when clone is used.
     * Override it to deep-clone nested objects and arrays.
     */
    public function __clone() {
        // Deep clone nested objects to prevent shared references:
        if ($this->body !== null) {
            $this->body = clone $this->body;
        }

        // Arrays are value types in PHP — they're deep-copied automatically by clone
        // Objects within arrays need manual deep cloning:
        $this->options = array_map(
            fn($v) => is_object($v) ? clone $v : $v,
            $this->options
        );
    }
}

When to Use Mutable vs Immutable

Use MUTABLE chaining when:
  ✅ The builder is used once and discarded (most common case)
  ✅ Performance is critical (cloning is slower)
  ✅ The chain is built in a single expression:
     (new Builder())->step1()->step2()->build()
  Examples: Most Laravel query builders, one-shot form processors

Use IMMUTABLE chaining when:
  ✅ The same base configuration is reused with different additions:
     $base = Builder::create()->withTimeout(30)->withRetries(3);
     $apiA = $base->withUrl('https://api-a.com');
     $apiB = $base->withUrl('https://api-b.com');
  ✅ Building configurations for multiple targets from a shared base
  ✅ The builder is passed around and modified by multiple actors
  ✅ Thread safety matters (PHP-CLI with pthreads/fibers)
  Examples: PSR-7 HTTP messages, Laravel's Pipeline, Symfony Config

 

Part 3 — The Fluent Interface Pattern

Method chaining is a mechanism. The Fluent Interface is a design pattern that uses method chaining to create a domain-specific language (DSL) that reads like natural language.

Designed for Readability: The Email Builder

<?php
/**
 * Fluent Email Builder
 * Reads like English: "Create an email to X, from Y, with subject Z, and body..."
 */
class Email {

    private array  $to      = [];
    private array  $cc      = [];
    private array  $bcc     = [];
    private string $from    = '';
    private string $subject = '';
    private string $body    = '';
    private bool   $isHtml  = false;
    private array  $attachments = [];
    private array  $headers = [];

    // ── Static factory — cleaner than 'new Email()' ────────────────────────
    public static function compose(): static {
        return new static();
    }

    // ── Recipient methods ──────────────────────────────────────────────────
    public function to(string ...$addresses): static {
        $this->to = array_merge($this->to, $addresses);
        return $this;
    }

    public function cc(string ...$addresses): static {
        $this->cc = array_merge($this->cc, $addresses);
        return $this;
    }

    public function bcc(string ...$addresses): static {
        $this->bcc = array_merge($this->bcc, $addresses);
        return $this;
    }

    public function from(string $address): static {
        $this->from = $address;
        return $this;
    }

    // ── Content methods ────────────────────────────────────────────────────
    public function subject(string $subject): static {
        $this->subject = $subject;
        return $this;
    }

    public function text(string $body): static {
        $this->body   = $body;
        $this->isHtml = false;
        return $this;
    }

    public function html(string $body): static {
        $this->body   = $body;
        $this->isHtml = true;
        return $this;
    }

    public function attach(string $filepath, string $alias = ''): static {
        $this->attachments[] = [
            'path'  => $filepath,
            'alias' => $alias ?: basename($filepath),
        ];
        return $this;
    }

    public function header(string $name, string $value): static {
        $this->headers[$name] = $value;
        return $this;
    }

    // ── Terminal method — actually sends ──────────────────────────────────
    public function send(): bool {
        $this->validate();
        // Build and send via your mail transport...
        return mail(
            implode(', ', $this->to),
            $this->subject,
            $this->body,
            $this->buildHeaders()
        );
    }

    // ── Validation before terminal action ─────────────────────────────────
    private function validate(): void {
        if (empty($this->to)) {
            throw new \InvalidArgumentException('Email must have at least one recipient.');
        }
        if (empty($this->subject)) {
            throw new \InvalidArgumentException('Email must have a subject.');
        }
        if (empty($this->from)) {
            throw new \InvalidArgumentException('Email must have a From address.');
        }
    }

    private function buildHeaders(): string {
        $headers = [
            'From'         => $this->from,
            'Content-Type' => $this->isHtml ? 'text/html; charset=utf-8' : 'text/plain; charset=utf-8',
        ];

        if ($this->cc)  $headers['Cc']  = implode(', ', $this->cc);
        if ($this->bcc) $headers['Bcc'] = implode(', ', $this->bcc);

        return implode("\r\n", array_map(
            fn($k, $v) => "{$k}: {$v}",
            array_keys($headers),
            $headers
        ));
    }
}

// ── Usage — reads exactly like a description of the email ─────────────────────
$sent = Email::compose()
    ->from('noreply@myapp.com')
    ->to('alice@example.com', 'bob@example.com')
    ->cc('manager@myapp.com')
    ->subject('Your Order Confirmation #12345')
    ->html('<h1>Thank you for your order!</h1><p>Order #12345 confirmed.</p>')
    ->attach('/invoices/order_12345.pdf')
    ->send();

 

HTML Builder — Chaining for Markup Generation

<?php
/**
 * Fluent HTML Builder
 * Generates structured HTML through a readable chain.
 */
class HtmlBuilder {

    private string $tag;
    private array  $attributes = [];
    private array  $children   = [];
    private ?string $text      = null;

    public function __construct(string $tag) {
        $this->tag = $tag;
    }

    public static function element(string $tag): static {
        return new static($tag);
    }

    // Shorthand factory methods for common elements
    public static function div(): static   { return new static('div'); }
    public static function p(): static     { return new static('p'); }
    public static function span(): static  { return new static('span'); }
    public static function ul(): static    { return new static('ul'); }
    public static function li(): static    { return new static('li'); }
    public static function a(): static     { return new static('a'); }
    public static function h(int $level): static { return new static("h{$level}"); }

    public function class(string ...$classes): static {
        $this->attributes['class'] = implode(' ', $classes);
        return $this;
    }

    public function id(string $id): static {
        $this->attributes['id'] = $id;
        return $this;
    }

    public function attr(string $name, string $value): static {
        $this->attributes[$name] = $value;
        return $this;
    }

    public function data(string $key, string $value): static {
        $this->attributes["data-{$key}"] = $value;
        return $this;
    }

    public function href(string $url): static {
        return $this->attr('href', $url);
    }

    public function text(string $text): static {
        $this->text = htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
        return $this;
    }

    public function rawHtml(string $html): static {
        $this->text = $html;
        return $this;
    }

    public function child(HtmlBuilder $child): static {
        $this->children[] = $child;
        return $this;
    }

    public function children(HtmlBuilder ...$children): static {
        $this->children = array_merge($this->children, $children);
        return $this;
    }

    public function render(): string {
        $attrs = '';
        foreach ($this->attributes as $name => $value) {
            $safeValue = htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
            $attrs .= " {$name}=\"{$safeValue}\"";
        }

        $content = $this->text ?? '';
        foreach ($this->children as $child) {
            $content .= $child->render();
        }

        return "<{$this->tag}{$attrs}>{$content}</{$this->tag}>";
    }

    public function __toString(): string {
        return $this->render();
    }
}

// ── Usage ──────────────────────────────────────────────────────────────────────
$nav = HtmlBuilder::element('nav')
    ->class('navbar', 'navbar-dark')
    ->id('main-nav')
    ->child(
        HtmlBuilder::ul()->class('nav-list')->children(
            HtmlBuilder::li()->child(
                HtmlBuilder::a()->href('/')->text('Home')
            ),
            HtmlBuilder::li()->child(
                HtmlBuilder::a()->href('/about')->text('About')
            ),
            HtmlBuilder::li()->child(
                HtmlBuilder::a()->href('/contact')->class('active')->text('Contact')
            )
        )
    );

echo $nav->render();
// Outputs: <nav class="navbar navbar-dark" id="main-nav"><ul class="nav-list">...

Part 4 — Production Query Builder

A step beyond the original’s 15-line example — this is a complete, production-usable query builder with joins, subqueries, pagination, and security.

<?php
declare(strict_types=1);

/**
 * Production PHP Query Builder
 *
 * Demonstrates advanced method chaining patterns:
 * - Conditional methods (when/unless)
 * - Callback-based subqueries
 * - Type-safe prepared statements
 * - Pagination support
 */
class QueryBuilder {

    private \PDO  $pdo;
    private string $table    = '';
    private array  $selects  = ['*'];
    private array  $joins    = [];
    private array  $wheres   = [];
    private array  $bindings = [];
    private array  $orderBys = [];
    private array  $groupBys = [];
    private ?string $having  = null;
    private ?int   $limit    = null;
    private ?int   $offset   = null;
    private bool   $distinct = false;

    public function __construct(\PDO $pdo) {
        $this->pdo = $pdo;
    }

    // ── SELECT clause ──────────────────────────────────────────────────────
    public function table(string $table): static {
        $this->table = $table;
        return $this;
    }

    public function select(string ...$columns): static {
        $this->selects = $columns;
        return $this;
    }

    public function addSelect(string ...$columns): static {
        $this->selects = array_merge($this->selects, $columns);
        return $this;
    }

    public function distinct(): static {
        $this->distinct = true;
        return $this;
    }

    // ── JOIN clauses ────────────────────────────────────────────────────────
    public function join(string $table, string $first, string $operator, string $second): static {
        $this->joins[] = "INNER JOIN {$table} ON {$first} {$operator} {$second}";
        return $this;
    }

    public function leftJoin(string $table, string $first, string $operator, string $second): static {
        $this->joins[] = "LEFT JOIN {$table} ON {$first} {$operator} {$second}";
        return $this;
    }

    public function rightJoin(string $table, string $first, string $operator, string $second): static {
        $this->joins[] = "RIGHT JOIN {$table} ON {$first} {$operator} {$second}";
        return $this;
    }

    // ── WHERE clauses ───────────────────────────────────────────────────────
    public function where(string $column, string $operator, mixed $value): static {
        $placeholder    = ':where_' . count($this->bindings);
        $this->wheres[] = "{$column} {$operator} {$placeholder}";
        $this->bindings[$placeholder] = $value;
        return $this;
    }

    public function whereNull(string $column): static {
        $this->wheres[] = "{$column} IS NULL";
        return $this;
    }

    public function whereNotNull(string $column): static {
        $this->wheres[] = "{$column} IS NOT NULL";
        return $this;
    }

    public function whereIn(string $column, array $values): static {
        $placeholders = [];
        foreach ($values as $value) {
            $key = ':wherein_' . count($this->bindings);
            $placeholders[]       = $key;
            $this->bindings[$key] = $value;
        }
        $this->wheres[] = "{$column} IN (" . implode(', ', $placeholders) . ")";
        return $this;
    }

    public function whereBetween(string $column, mixed $from, mixed $to): static {
        $fromKey = ':between_from_' . count($this->bindings);
        $toKey   = ':between_to_' . (count($this->bindings) + 1);
        $this->wheres[]          = "{$column} BETWEEN {$fromKey} AND {$toKey}";
        $this->bindings[$fromKey] = $from;
        $this->bindings[$toKey]   = $to;
        return $this;
    }

    /**
     * Callback-based grouped WHERE:
     * ->where(fn($q) => $q->where('a', '=', 1)->orWhere('b', '=', 2))
     */
    public function whereGroup(callable $callback): static {
        $nested = new static($this->pdo);
        $callback($nested);

        if (!empty($nested->wheres)) {
            $this->wheres[]  = '(' . implode(' AND ', $nested->wheres) . ')';
            $this->bindings  = array_merge($this->bindings, $nested->bindings);
        }
        return $this;
    }

    public function orWhere(string $column, string $operator, mixed $value): static {
        $placeholder    = ':orwhere_' . count($this->bindings);
        $this->wheres[] = "OR {$column} {$operator} {$placeholder}";
        $this->bindings[$placeholder] = $value;
        return $this;
    }

    // ── ORDER BY, GROUP BY ─────────────────────────────────────────────────
    public function orderBy(string $column, string $direction = 'ASC'): static {
        $direction = strtoupper($direction) === 'DESC' ? 'DESC' : 'ASC';
        $this->orderBys[] = "{$column} {$direction}";
        return $this;
    }

    public function orderByDesc(string $column): static {
        return $this->orderBy($column, 'DESC');
    }

    public function groupBy(string ...$columns): static {
        $this->groupBys = array_merge($this->groupBys, $columns);
        return $this;
    }

    public function having(string $condition): static {
        $this->having = $condition;
        return $this;
    }

    // ── LIMIT / OFFSET / Pagination ────────────────────────────────────────
    public function limit(int $limit): static {
        $this->limit = $limit;
        return $this;
    }

    public function offset(int $offset): static {
        $this->offset = $offset;
        return $this;
    }

    public function forPage(int $page, int $perPage = 15): static {
        return $this->limit($perPage)->offset(($page - 1) * $perPage);
    }

    // ── Conditional chaining — The Laravel-inspired pattern ────────────────

    /**
     * Apply the callback only if the condition is truthy.
     * The most powerful method in any query builder.
     *
     * Usage:
     *   ->when($request->status, fn($q, $v) => $q->where('status', '=', $v))
     *   ->when($onlyActive, fn($q) => $q->where('active', '=', 1))
     */
    public function when(mixed $condition, callable $callback, ?callable $default = null): static {
        if ($condition) {
            $callback($this, $condition);
        } elseif ($default !== null) {
            $default($this);
        }
        return $this;
    }

    /**
     * Apply the callback only if the condition is falsy.
     * The inverse of when().
     */
    public function unless(mixed $condition, callable $callback): static {
        return $this->when(!$condition, $callback);
    }

    /**
     * Tap into the chain to inspect or side-effect without breaking flow.
     * Extremely useful for debugging.
     *
     * Usage:
     *   ->tap(fn($q) => dump($q->toSql()))  ← Debug without breaking chain
     *   ->tap(fn($q) => logger()->info('Query state', ['sql' => $q->toSql()]))
     */
    public function tap(callable $callback): static {
        $callback($this);
        return $this;
    }

    /**
     * Apply a callback and return $this regardless.
     * Allows complex logic to be inserted into the chain.
     */
    public function pipe(callable $callback): static {
        $callback($this);
        return $this;
    }

    // ── SQL generation ─────────────────────────────────────────────────────
    public function toSql(): string {
        $distinct = $this->distinct ? 'DISTINCT ' : '';
        $sql      = "SELECT {$distinct}" . implode(', ', $this->selects);
        $sql     .= " FROM {$this->table}";

        if ($this->joins)   $sql .= ' ' . implode(' ', $this->joins);
        if ($this->wheres)  $sql .= ' WHERE ' . implode(' AND ', $this->wheres);
        if ($this->groupBys)$sql .= ' GROUP BY ' . implode(', ', $this->groupBys);
        if ($this->having)  $sql .= " HAVING {$this->having}";
        if ($this->orderBys)$sql .= ' ORDER BY ' . implode(', ', $this->orderBys);
        if ($this->limit !== null) $sql .= " LIMIT {$this->limit}";
        if ($this->offset !== null)$sql .= " OFFSET {$this->offset}";

        return $sql;
    }

    // ── Terminal methods — actually execute the query ──────────────────────
    public function get(): array {
        $stmt = $this->pdo->prepare($this->toSql());
        $stmt->execute($this->bindings);
        return $stmt->fetchAll(\PDO::FETCH_ASSOC);
    }

    public function first(): ?array {
        $results = $this->limit(1)->get();
        return $results[0] ?? null;
    }

    public function count(): int {
        $original = $this->selects;
        $this->selects = ['COUNT(*) AS aggregate'];
        $result = $this->first();
        $this->selects = $original;
        return (int)($result['aggregate'] ?? 0);
    }

    public function exists(): bool {
        return $this->count() > 0;
    }

    public function value(string $column): mixed {
        $row = $this->select($column)->first();
        return $row[$column] ?? null;
    }

    public function pluck(string $column): array {
        return array_column($this->select($column)->get(), $column);
    }
}

// ── Real-world usage showing the power of conditional chaining ────────────────
function searchUsers(\PDO $pdo, array $filters): array {
    $qb = new QueryBuilder($pdo);

    return $qb
        ->table('users u')
        ->select('u.id', 'u.name', 'u.email', 'r.name AS role')
        ->leftJoin('roles r', 'r.id', '=', 'u.role_id')
        ->whereNull('u.deleted_at')

        // Conditional filters — each applies only when the value is present
        ->when($filters['search'] ?? null,
            fn($q, $v) => $q->whereGroup(fn($inner) => $inner
                ->where('u.name', 'LIKE', "%{$v}%")
                ->orWhere('u.email', 'LIKE', "%{$v}%")
            )
        )
        ->when($filters['status'] ?? null,
            fn($q, $v) => $q->where('u.status', '=', $v)
        )
        ->when($filters['role_id'] ?? null,
            fn($q, $v) => $q->where('u.role_id', '=', $v)
        )
        ->when($filters['country'] ?? null,
            fn($q, $v) => $q->where('u.country', '=', $v)
        )
        ->when(isset($filters['min_age']),
            fn($q) => $q->where('u.age', '>=', $filters['min_age'])
        )
        ->unless($filters['include_inactive'] ?? false,
            fn($q) => $q->where('u.active', '=', 1)
        )

        // Tap for debugging — remove in production
        ->tap(fn($q) => error_log('[QueryBuilder] SQL: ' . $q->toSql()))

        // Sort and paginate
        ->orderBy($filters['sort'] ?? 'u.created_at', $filters['direction'] ?? 'DESC')
        ->forPage($filters['page'] ?? 1, $filters['per_page'] ?? 15)
        ->get();
}

// Usage:
$users = searchUsers($pdo, [
    'search'    => 'alice',
    'status'    => 'active',
    'page'      => 2,
    'per_page'  => 20,
    // 'role_id'  and 'country' omitted — those where() calls are skipped
]);

 

Part 5 — Static Factory Method Chaining

The original article shows static chaining as a secondary example. It’s actually a more common pattern in modern PHP because it avoids new ClassName() at the call site.

<?php
/**
 * HTTP Request Builder — fully static factory entry point
 * Demonstrates static chaining with instance method continuation.
 */
class HttpRequest {

    private string  $method   = 'GET';
    private string  $url      = '';
    private array   $headers  = [];
    private mixed   $body     = null;
    private int     $timeout  = 30;
    private bool    $verifySsl = true;
    private ?string $bearerToken = null;
    private array   $queryParams = [];

    // ── Static factory methods — clean entry points ──────────────────────────
    public static function get(string $url): static {
        $request = new static();
        $request->method = 'GET';
        $request->url    = $url;
        return $request;
    }

    public static function post(string $url): static {
        $request = new static();
        $request->method = 'POST';
        $request->url    = $url;
        return $request;
    }

    public static function put(string $url): static {
        $request = new static();
        $request->method = 'PUT';
        $request->url    = $url;
        return $request;
    }

    public static function patch(string $url): static {
        $request = new static();
        $request->method = 'PATCH';
        $request->url    = $url;
        return $request;
    }

    public static function delete(string $url): static {
        $request = new static();
        $request->method = 'DELETE';
        $request->url    = $url;
        return $request;
    }

    // ── Fluent configuration methods ─────────────────────────────────────────
    public function withHeader(string $name, string $value): static {
        $clone = clone $this;
        $clone->headers[$name] = $value;
        return $clone;
    }

    public function withHeaders(array $headers): static {
        $clone = clone $this;
        $clone->headers = array_merge($clone->headers, $headers);
        return $clone;
    }

    public function withBearerToken(string $token): static {
        return $this->withHeader('Authorization', "Bearer {$token}");
    }

    public function withBasicAuth(string $user, string $pass): static {
        return $this->withHeader('Authorization', 'Basic ' . base64_encode("{$user}:{$pass}"));
    }

    public function withJson(array $data): static {
        $clone = clone $this;
        $clone->body    = json_encode($data, JSON_THROW_ON_ERROR);
        $clone->headers['Content-Type'] = 'application/json';
        return $clone;
    }

    public function withFormData(array $data): static {
        $clone = clone $this;
        $clone->body    = http_build_query($data);
        $clone->headers['Content-Type'] = 'application/x-www-form-urlencoded';
        return $clone;
    }

    public function withQueryParam(string $key, mixed $value): static {
        $clone = clone $this;
        $clone->queryParams[$key] = $value;
        return $clone;
    }

    public function withQueryParams(array $params): static {
        $clone = clone $this;
        $clone->queryParams = array_merge($clone->queryParams, $params);
        return $clone;
    }

    public function withTimeout(int $seconds): static {
        $clone = clone $this;
        $clone->timeout = $seconds;
        return $clone;
    }

    public function withoutSslVerification(): static {
        $clone = clone $this;
        $clone->verifySsl = false;
        return $clone;
    }

    // ── Terminal method — executes the request ─────────────────────────────
    public function send(): HttpResponse {
        $url = $this->url;
        if (!empty($this->queryParams)) {
            $url .= '?' . http_build_query($this->queryParams);
        }

        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_CUSTOMREQUEST  => $this->method,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => $this->timeout,
            CURLOPT_SSL_VERIFYPEER => $this->verifySsl,
            CURLOPT_HTTPHEADER     => array_map(
                fn($k, $v) => "{$k}: {$v}",
                array_keys($this->headers),
                $this->headers
            ),
        ]);

        if ($this->body !== null) {
            curl_setopt($ch, CURLOPT_POSTFIELDS, $this->body);
        }

        $body     = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $errno    = curl_errno($ch);
        $error    = curl_error($ch);
        curl_close($ch);

        if ($errno !== 0) {
            throw new \RuntimeException("HTTP request failed: {$error}");
        }

        return new HttpResponse($httpCode, (string)$body);
    }
}

class HttpResponse {
    public function __construct(
        public readonly int    $status,
        public readonly string $body
    ) {}

    public function json(): array {
        return json_decode($this->body, true, 512, JSON_THROW_ON_ERROR);
    }

    public function ok(): bool {
        return $this->status >= 200 && $this->status < 300;
    }
}

// ── Clean, self-documenting API call code ──────────────────────────────────────
$response = HttpRequest::post('https://api.example.com/users')
    ->withBearerToken('my_api_token_here')
    ->withJson(['name' => 'Alice', 'email' => 'alice@example.com'])
    ->withTimeout(15)
    ->send();

if ($response->ok()) {
    $user = $response->json();
    echo "Created user ID: " . $user['id'];
}

// Reusing a base configuration (immutable pattern):
$apiClient = HttpRequest::get('https://api.example.com')
    ->withBearerToken(getenv('API_TOKEN'))
    ->withHeader('Accept', 'application/json')
    ->withTimeout(10);

// These derive new objects from $apiClient — no shared mutation:
$users    = $apiClient->withQueryParams(['page' => 1, 'status' => 'active'])
                      ->send()
                      ->json();

$products = HttpRequest::get('https://api.example.com/products')
                ->withBearerToken(getenv('API_TOKEN'))
                ->withHeader('Accept', 'application/json')
                ->withTimeout(10)
                ->send()
                ->json();

Part 6 — Validation Chain Builder

<?php
/**
 * Fluent Validation Library
 * Shows how chaining creates a readable rule definition language.
 */
class Validator {

    private array  $rules   = [];
    private array  $errors  = [];
    private array  $data    = [];
    private string $field   = '';

    public static function make(array $data): static {
        $instance = new static();
        $instance->data = $data;
        return $instance;
    }

    // ── Field selector — switches context to a specific field ──────────────
    public function field(string $name): static {
        $this->field = $name;
        return $this;
    }

    // ── Validation rules ───────────────────────────────────────────────────
    public function required(): static {
        $value = $this->data[$this->field] ?? null;
        if ($value === null || $value === '') {
            $this->errors[$this->field][] = "{$this->field} is required.";
        }
        return $this;
    }

    public function string(): static {
        $value = $this->data[$this->field] ?? null;
        if ($value !== null && !is_string($value)) {
            $this->errors[$this->field][] = "{$this->field} must be a string.";
        }
        return $this;
    }

    public function minLength(int $min): static {
        $value = $this->data[$this->field] ?? '';
        if (strlen((string)$value) < $min) {
            $this->errors[$this->field][] = "{$this->field} must be at least {$min} characters.";
        }
        return $this;
    }

    public function maxLength(int $max): static {
        $value = $this->data[$this->field] ?? '';
        if (strlen((string)$value) > $max) {
            $this->errors[$this->field][] = "{$this->field} may not be longer than {$max} characters.";
        }
        return $this;
    }

    public function email(): static {
        $value = $this->data[$this->field] ?? '';
        if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
            $this->errors[$this->field][] = "{$this->field} must be a valid email address.";
        }
        return $this;
    }

    public function integer(): static {
        $value = $this->data[$this->field] ?? null;
        if ($value !== null && !filter_var($value, FILTER_VALIDATE_INT)) {
            $this->errors[$this->field][] = "{$this->field} must be an integer.";
        }
        return $this;
    }

    public function min(int $min): static {
        $value = $this->data[$this->field] ?? 0;
        if ((int)$value < $min) {
            $this->errors[$this->field][] = "{$this->field} must be at least {$min}.";
        }
        return $this;
    }

    public function max(int $max): static {
        $value = $this->data[$this->field] ?? 0;
        if ((int)$value > $max) {
            $this->errors[$this->field][] = "{$this->field} must not be greater than {$max}.";
        }
        return $this;
    }

    public function in(array $allowed): static {
        $value = $this->data[$this->field] ?? null;
        if ($value !== null && !in_array($value, $allowed, true)) {
            $this->errors[$this->field][] = "{$this->field} must be one of: " . implode(', ', $allowed) . ".";
        }
        return $this;
    }

    public function url(): static {
        $value = $this->data[$this->field] ?? '';
        if (!filter_var($value, FILTER_VALIDATE_URL)) {
            $this->errors[$this->field][] = "{$this->field} must be a valid URL.";
        }
        return $this;
    }

    public function custom(callable $rule, string $message): static {
        $value = $this->data[$this->field] ?? null;
        if (!$rule($value, $this->data)) {
            $this->errors[$this->field][] = $message;
        }
        return $this;
    }

    // ── Terminal methods ───────────────────────────────────────────────────
    public function passes(): bool {
        return empty($this->errors);
    }

    public function fails(): bool {
        return !$this->passes();
    }

    public function errors(): array {
        return $this->errors;
    }

    public function firstError(string $field): ?string {
        return $this->errors[$field][0] ?? null;
    }

    public function validated(): array {
        if ($this->fails()) {
            throw new \RuntimeException(
                'Validation failed: ' . implode(', ', array_merge(...array_values($this->errors)))
            );
        }
        // Return only the validated fields
        return array_intersect_key($this->data, array_flip(array_keys($this->data)));
    }
}

// ── Usage — incredibly readable validation rules ──────────────────────────────
$validator = Validator::make($_POST)
    ->field('name')
        ->required()
        ->string()
        ->minLength(2)
        ->maxLength(100)
    ->field('email')
        ->required()
        ->email()
    ->field('age')
        ->required()
        ->integer()
        ->min(18)
        ->max(120)
    ->field('role')
        ->required()
        ->in(['admin', 'editor', 'viewer'])
    ->field('website')
        ->url()
    ->field('username')
        ->required()
        ->minLength(3)
        ->custom(
            fn($v) => !str_contains($v, ' '),
            'username must not contain spaces.'
        );

if ($validator->fails()) {
    // Return validation errors as JSON
    http_response_code(422);
    echo json_encode(['errors' => $validator->errors()]);
    exit;
}

$data = $validator->validated();

 

Part 7 — PHP 8 Features That Change How You Chain

Nullsafe Operator (?->) — Chaining on Nullable Results

PHP 8.0 introduced the nullsafe operator, which fundamentally changes how you handle chains that might produce null:

<?php
class User {
    public function __construct(
        public readonly int    $id,
        public readonly string $name,
        private ?Address $address = null
    ) {}

    public function getAddress(): ?Address { return $this->address; }
}

class Address {
    public function __construct(
        private ?City $city = null
    ) {}
    public function getCity(): ?City { return $this->city; }
}

class City {
    public function __construct(
        public readonly string $name,
        public readonly string $country
    ) {}
    public function getPostCode(): ?string { return '110001'; }
}

// ❌ Old PHP 7 approach — verbose null-checking:
$user = getUser(42);
$postCode = null;
if ($user !== null) {
    $address = $user->getAddress();
    if ($address !== null) {
        $city = $address->getCity();
        if ($city !== null) {
            $postCode = $city->getPostCode();
        }
    }
}

// ✅ PHP 8 nullsafe operator — short-circuits on null:
$postCode = getUser(42)?->getAddress()?->getCity()?->getPostCode();
// If ANY step returns null, the entire expression evaluates to null (no error)

// ── Combining nullsafe with method chains ──────────────────────────────────────
// The nullsafe operator works with your own builder methods too:
$result = getOptionalBuilder()?->where('status', '=', 'active')?->get();

// Short-circuit with null coalescing:
$cityName = getUser(42)?->getAddress()?->getCity()?->name ?? 'Unknown';

Named Arguments + Method Chains

PHP 8.0 named arguments are sometimes a better alternative to chains for configuration:

<?php
// ── Method chain (good for sequential logic, complex conditionals) ─────────────
$user = (new User())
    ->setName('Alice')
    ->setEmail('alice@example.com')
    ->setRole('admin')
    ->setCountry('IN')
    ->build();

// ── Named arguments (good for simple value objects, optional params) ───────────
class UserData {
    public function __construct(
        public readonly string  $name,
        public readonly string  $email,
        public readonly string  $role    = 'viewer',
        public readonly string  $country = 'US',
        public readonly bool    $active  = true,
    ) {}
}

$user = new UserData(
    name:    'Alice',
    email:   'alice@example.com',
    role:    'admin',
    country: 'IN',
    // 'active' omitted — uses default true
);

// ── When to prefer one over the other ─────────────────────────────────────────
// Use METHOD CHAINING when:
//   - Steps have side effects or depend on each other
//   - You need conditional steps (when/unless)
//   - The order matters for logic (e.g., query builder)
//   - You're building something incrementally

// Use NAMED ARGUMENTS when:
//   - Simple value object construction
//   - All values are independent of each other
//   - You want PHP to validate types at construction time
//   - You have 3+ optional parameters with defaults

Readonly Properties and Chaining (PHP 8.1+)

<?php
// PHP 8.1 readonly properties work perfectly with immutable chaining:
class Config {
    public function __construct(
        public readonly string $host     = 'localhost',
        public readonly int    $port     = 3306,
        public readonly string $database = '',
        public readonly string $charset  = 'utf8mb4',
        public readonly int    $timeout  = 30,
    ) {}

    // Immutable "with" methods — create new instance with one property changed
    public function withHost(string $host): static {
        return new static($host, $this->port, $this->database, $this->charset, $this->timeout);
    }

    public function withPort(int $port): static {
        return new static($this->host, $port, $this->database, $this->charset, $this->timeout);
    }

    public function withDatabase(string $database): static {
        return new static($this->host, $this->port, $database, $this->charset, $this->timeout);
    }

    public function withTimeout(int $timeout): static {
        return new static($this->host, $this->port, $this->database, $this->charset, $timeout);
    }
}

$baseConfig = new Config(host: 'db.example.com', database: 'myapp');

$readConfig  = $baseConfig->withTimeout(5);      // Read queries: short timeout
$writeConfig = $baseConfig->withPort(3307);       // Write to different port/replica

// All three are separate objects — no shared mutation:
var_dump($baseConfig  === $readConfig);   // false
var_dump($baseConfig  === $writeConfig);  // false

 

Part 8 — Error Handling in Chains

The original article notes “debugging is harder if one method fails” and leaves it there. Here’s how to handle errors properly:

Strategy 1: Throw Exceptions (Recommended)

<?php
class SafeQueryBuilder {
    private string $table = '';

    public function table(string $table): static {
        if (empty(trim($table))) {
            throw new \InvalidArgumentException('Table name cannot be empty.');
        }
        // Sanitise table name (whitelist allowed characters):
        if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $table)) {
            throw new \InvalidArgumentException(
                "Invalid table name: '{$table}'. Only letters, numbers, and underscores allowed."
            );
        }
        $this->table = $table;
        return $this;
    }

    public function where(string $column, string $operator, mixed $value): static {
        $allowedOperators = ['=', '!=', '<', '>', '<=', '>=', 'LIKE', 'NOT LIKE', 'IN'];
        if (!in_array(strtoupper($operator), $allowedOperators, true)) {
            throw new \InvalidArgumentException("Invalid operator: '{$operator}'.");
        }
        // ... rest of implementation
        return $this;
    }
}

// Usage with try/catch wrapping the entire chain:
try {
    $results = (new SafeQueryBuilder())
        ->table('users')
        ->where('status', '=', 'active')
        ->where('age', '>=', 18)
        ->get();
} catch (\InvalidArgumentException $e) {
    echo "Configuration error: " . $e->getMessage();
} catch (\PDOException $e) {
    echo "Database error: " . $e->getMessage();
}

Strategy 2: Result Object Pattern (for Non-Fatal Errors)

<?php
/**
 * Chain that accumulates errors without throwing.
 * Good for form builders, validation chains, or import pipelines
 * where you want to collect ALL errors before stopping.
 */
class DataPipeline {

    private array $data;
    private array $errors   = [];
    private bool  $failed   = false;

    public function __construct(array $data) {
        $this->data = $data;
    }

    public static function from(array $data): static {
        return new static($data);
    }

    public function transform(callable $callback): static {
        if ($this->failed) return $this; // Short-circuit: skip transforms after failure

        try {
            $this->data = $callback($this->data);
        } catch (\Throwable $e) {
            $this->errors[] = $e->getMessage();
            $this->failed   = true;
        }

        return $this;
    }

    public function validate(callable $rule, string $errorMessage): static {
        if ($this->failed) return $this;

        if (!$rule($this->data)) {
            $this->errors[] = $errorMessage;
            $this->failed   = true;
        }

        return $this;
    }

    // Terminal methods:
    public function get(): array {
        if ($this->failed) {
            throw new \RuntimeException("Pipeline failed: " . implode('; ', $this->errors));
        }
        return $this->data;
    }

    public function successful(): bool { return !$this->failed; }
    public function errors(): array    { return $this->errors; }
}

// Usage:
$result = DataPipeline::from($_POST)
    ->validate(fn($d) => !empty($d['email']),          'Email is required.')
    ->validate(fn($d) => filter_var($d['email'], FILTER_VALIDATE_EMAIL), 'Invalid email.')
    ->transform(fn($d) => array_merge($d, ['email' => strtolower($d['email'])]))
    ->transform(fn($d) => array_merge($d, ['name' => trim($d['name'] ?? '')]))
    ->validate(fn($d) => strlen($d['name']) >= 2,      'Name too short.');

if ($result->successful()) {
    $data = $result->get();
} else {
    echo implode(', ', $result->errors());
}

 

Part 9 — Debugging Chains: The tap() Pattern

The tap() method (popularised by Laravel) lets you insert debug statements into a chain without breaking it:

<?php
// ── Debugging without tap — breaks the chain ──────────────────────────────────
$qb = (new QueryBuilder($pdo))->table('users')->where('status', '=', 'active');
echo $qb->toSql();  // Must break the chain to see this
$results = $qb->orderBy('name')->get();

// ── Debugging WITH tap — chain flows naturally ─────────────────────────────────
$results = (new QueryBuilder($pdo))
    ->table('users')
    ->where('status', '=', 'active')
    ->tap(fn($q) => echo "After where: " . $q->toSql() . "\n")  // ← Inspect here
    ->orderBy('name')
    ->tap(fn($q) => echo "After orderBy: " . $q->toSql() . "\n") // ← And here
    ->limit(20)
    ->get();

// ── Tap for logging ───────────────────────────────────────────────────────────
$results = (new QueryBuilder($pdo))
    ->table('orders')
    ->where('status', '=', 'pending')
    ->tap(fn($q) => error_log("[QueryBuilder] Executing: " . $q->toSql()))
    ->get();

// ── Tap for performance measurement ──────────────────────────────────────────
$startTime = null;
$results = (new QueryBuilder($pdo))
    ->table('products')
    ->tap(fn() => $startTime = microtime(true))
    ->where('in_stock', '=', 1)
    ->get();
$elapsed = microtime(true) - $startTime;
error_log("Query took: " . round($elapsed * 1000) . "ms");

 

Part 10 — Testing Method Chains

<?php
use PHPUnit\Framework\TestCase;

class QueryBuilderTest extends TestCase {

    private \PDO $pdo;

    protected function setUp(): void {
        // Use SQLite in-memory database for fast, isolated tests:
        $this->pdo = new \PDO('sqlite::memory:');
        $this->pdo->exec("
            CREATE TABLE users (
                id INTEGER PRIMARY KEY,
                name TEXT, email TEXT, status TEXT,
                age INTEGER, country TEXT, role_id INTEGER
            )
        ");
        $this->pdo->exec("INSERT INTO users VALUES (1,'Alice','alice@test.com','active',28,'IN',1)");
        $this->pdo->exec("INSERT INTO users VALUES (2,'Bob','bob@test.com','inactive',35,'US',2)");
    }

    /** Test that the chain produces the correct SQL */
    public function testBasicChainProducesCorrectSql(): void {
        $qb  = new QueryBuilder($this->pdo);
        $sql = $qb->table('users')
                  ->select('id', 'name')
                  ->where('status', '=', 'active')
                  ->orderBy('name')
                  ->limit(10)
                  ->toSql();

        $this->assertStringContainsString('SELECT id, name FROM users', $sql);
        $this->assertStringContainsString('WHERE', $sql);
        $this->assertStringContainsString('ORDER BY name ASC', $sql);
        $this->assertStringContainsString('LIMIT 10', $sql);
    }

    /** Test that each method returns the same instance (mutable chaining) */
    public function testMethodsReturnSameInstance(): void {
        $qb = new QueryBuilder($this->pdo);

        $result = $qb->table('users');
        $this->assertSame($qb, $result, 'table() must return $this');

        $result = $qb->where('id', '=', 1);
        $this->assertSame($qb, $result, 'where() must return $this');

        $result = $qb->orderBy('name');
        $this->assertSame($qb, $result, 'orderBy() must return $this');
    }

    /** Test conditional chaining with when() */
    public function testWhenAppliesCallbackWhenTruthy(): void {
        $qb = new QueryBuilder($this->pdo);

        $withFilter = $qb->table('users')
                         ->when('active', fn($q, $v) => $q->where('status', '=', $v))
                         ->toSql();

        $this->assertStringContainsString('WHERE', $withFilter);
    }

    public function testWhenSkipsCallbackWhenFalsy(): void {
        $qb = new QueryBuilder($this->pdo);

        $withoutFilter = $qb->table('users')
                            ->when('', fn($q, $v) => $q->where('status', '=', $v))
                            ->toSql();

        $this->assertStringNotContainsString('WHERE', $withoutFilter);
    }

    /** Test actual data retrieval */
    public function testGetReturnsCorrectData(): void {
        $qb      = new QueryBuilder($this->pdo);
        $results = $qb->table('users')
                      ->where('status', '=', 'active')
                      ->get();

        $this->assertCount(1, $results);
        $this->assertEquals('Alice', $results[0]['name']);
    }

    /** Test that tap() does not alter the chain result */
    public function testTapDoesNotAlterChain(): void {
        $qb      = new QueryBuilder($this->pdo);
        $tapped  = false;

        $results = $qb->table('users')
                      ->tap(function() use (&$tapped) { $tapped = true; })
                      ->get();

        $this->assertTrue($tapped, 'tap() callback must be called');
        $this->assertIsArray($results, 'tap() must not alter the chain return type');
    }

    /** Test immutable builder */
    public function testImmutableBuilderDoesNotShareState(): void {
        $base  = ImmutableBuilder::create()->where('status', 'active');
        $admins  = $base->where('role', 'admin');
        $editors = $base->where('role', 'editor');

        $this->assertNotSame($base, $admins);
        $this->assertNotSame($base, $editors);
        $this->assertNotSame($admins, $editors);

        $this->assertCount(1, $base->getFilters());
        $this->assertCount(2, $admins->getFilters());
        $this->assertCount(2, $editors->getFilters());
    }
}

 

Part 11 — When NOT to Use Method Chaining

Knowing when NOT to chain is as important as knowing how. The original article says “don’t overuse it” with no concrete guidance. Here are the real cases where chaining makes code worse:

Anti-Pattern 1: Chaining Side-Effect Methods

<?php
// ❌ BAD: These methods do unrelated things — chaining obscures the fact
//         that each is a distinct operation with side effects
(new Logger())
    ->writeToFile('error.log')   // Side effect: disk write
    ->sendEmail('admin@site.com') // Side effect: network call
    ->updateDatabase()            // Side effect: database write
    ->sendSMS('+919876543210');    // Side effect: SMS API call

// ✅ BETTER: Explicit sequential calls — the side effects are clear
$logger = new Logger();
$logger->writeToFile('error.log');
$logger->sendEmail('admin@site.com');
$logger->updateDatabase();
$logger->sendSMS('+919876543210');

Anti-Pattern 2: Chaining Across Different Concerns

<?php
// ❌ BAD: One chain doing authentication + database + email + formatting
$result = $auth->login($user)       // Auth concern
               ->fetchOrders()      // Database concern
               ->formatAsCsv()      // Formatting concern
               ->sendToEmail();     // Email concern

// ✅ BETTER: Separate concerns, pass data between them
$session  = $auth->login($user);
$orders   = $orderRepo->getForUser($session->userId());
$csv      = $formatter->toCSV($orders);
$mailer->send($session->email(), $csv);

Anti-Pattern 3: Very Long Chains Without Intermediate Variables

<?php
// ❌ BAD: 15-step chain — debugging requires extracting every step
$result = (new Processor())
    ->loadFile($path)->parse()->validate()->transform()->filter()->sort()
    ->group()->aggregate()->format()->compress()->encrypt()->sign()
    ->cache()->log()->send();

// ✅ BETTER: Group logical phases into intermediate variables
$parsed    = (new Processor())->loadFile($path)->parse()->validate();
$processed = $parsed->transform()->filter()->sort()->group()->aggregate();
$output    = $processed->format()->compress()->encrypt()->sign();
$output->cache()->log()->send();
// Now you can inspect $parsed, $processed, and $output independently

Anti-Pattern 4: Chaining Instead of Configuration

<?php
// ❌ VERBOSE: 10-line chain for simple object configuration
$config = (new DatabaseConfig())
    ->setHost('localhost')
    ->setPort(3306)
    ->setName('myapp')
    ->setUser('dbuser')
    ->setPassword('secret')
    ->setCharset('utf8mb4')
    ->setTimeout(30)
    ->setRetries(3)
    ->setSslMode('required')
    ->build();

// ✅ CLEANER for this case: Constructor with named arguments (PHP 8.0+)
$config = new DatabaseConfig(
    host:     'localhost',
    port:     3306,
    name:     'myapp',
    user:     'dbuser',
    password: 'secret',
    charset:  'utf8mb4',
    timeout:  30,
    retries:  3,
    sslMode:  'required',
);

// ✅ OR: Array configuration (for when number of options varies)
$config = DatabaseConfig::fromArray([
    'host'     => 'localhost',
    'port'     => 3306,
    'name'     => 'myapp',
    'user'     => 'dbuser',
    'password' => 'secret',
]);

 

Part 12 — Real-World Framework Chaining Explained

How Laravel’s Eloquent Actually Works

<?php
// Laravel's Eloquent chain:
$users = User::where('status', 'active')
             ->where('role', 'admin')
             ->orderBy('created_at', 'desc')
             ->with('permissions')
             ->paginate(15);

// What happens internally:
// 1. User::where()        → calls static __callStatic() → creates a new Builder
// 2. ->where()            → Builder::where() returns $this (mutable)
// 3. ->orderBy()          → Builder::orderBy() returns $this
// 4. ->with()             → adds eager load relationship (still Builder, returns $this)
// 5. ->paginate()         → terminal method: executes COUNT query + SELECT query
//                            returns LengthAwarePaginator (NOT Builder)

// The key insight: Eloquent's Builder wraps a query state object.
// Each chainable method modifies this state and returns $this.
// Terminal methods (get, first, paginate, count, etc.) execute the query.

How Symfony’s Form Builder Works

<?php
// Symfony Form Builder:
$form = $this->createFormBuilder($user)
    ->add('name', TextType::class, ['label' => 'Full Name'])
    ->add('email', EmailType::class)
    ->add('role', ChoiceType::class, ['choices' => ['Admin' => 'admin', 'User' => 'user']])
    ->add('submit', SubmitType::class, ['label' => 'Save'])
    ->getForm();

// The FormBuilder::add() method registers field configurations
// and returns $this. getForm() is the terminal method that
// builds the actual Form object from the accumulated configuration.

How PHP-DI / Container Builders Work

<?php
// PHP-DI Container builder:
$container = (new ContainerBuilder())
    ->enableCompilation('/tmp/di-cache')
    ->writeProxiesToFile(true, '/tmp/di-proxies')
    ->addDefinitions([
        LoggerInterface::class => create(FileLogger::class)->constructor('/var/log/app.log'),
        CacheInterface::class  => factory(fn() => new RedisCache()),
        'config'               => value(['debug' => false, 'timezone' => 'UTC']),
    ])
    ->build();  // Terminal method: builds and returns the Container instance

 

Summary: The Complete Method Chaining Reference

MECHANICS
────────────────────────────────────────────────────────────────────
Return type for chainable methods:  static (not self, not void)
Entry point options:                new ClassName() or ClassName::create()
Terminal methods:                   Return data, not $this
Debugging in chains:                Use ->tap(fn($x) => dump($x))

MUTABLE VS IMMUTABLE
────────────────────────────────────────────────────────────────────
Mutable:    return $this;           ← Same object, modified in place
Immutable:  $c = clone $this; ...; return $c;  ← New object each step

Use mutable when:   Single-use chains built left-to-right
Use immutable when: Reusing a base configuration for multiple variants

PHP 8+ FEATURES
────────────────────────────────────────────────────────────────────
Nullsafe:       ?->method()         ← Short-circuits on null
Named args:     Alternative to chains for simple construction
Readonly:       Works perfectly with immutable chain pattern
Match:          Works inside chain method bodies

CONDITIONAL CHAINING
────────────────────────────────────────────────────────────────────
when($cond, fn($q, $v) => ...)    ← Apply if truthy
unless($cond, fn($q) => ...)      ← Apply if falsy
tap(fn($q) => ...)                ← Inspect without breaking chain

ERROR HANDLING
────────────────────────────────────────────────────────────────────
Throw exceptions:   Best for programming errors (wrong input)
Result objects:     Best for collecting multiple validation errors
Wrap entire chain:  try { chain } catch (\Exception $e) { ... }

WHEN TO CHAIN
────────────────────────────────────────────────────────────────────
✅ Building queries, HTTP requests, emails, HTML
✅ Configuration that has conditional steps
✅ Sequential operations on the same subject
✅ Creating readable DSLs for specific domains

WHEN NOT TO CHAIN
────────────────────────────────────────────────────────────────────
❌ Side-effect methods doing unrelated things
❌ Chains longer than ~8 steps without logical grouping
❌ Simple object construction with fixed parameters (use named args)
❌ Operations across different concerns (auth + db + email in one chain)

 

The original article’s three examples cover the “how” of method chaining: return $this. This guide covers the “why,” the “when,” and the “what else” — the full picture that turns a syntactic trick into an architectural decision.

Method chaining at its best creates code that reads like a description of what it does: Email::compose()->to(recipients)->from(sender)->subject(subject)->html(body)->send(). The intent is visible at a glance. The alternatives — arrays of options, multiple function calls, complex config objects — each have their place, but none matches the narrative clarity of a well-designed fluent interface.

The patterns in this guide — immutable chaining, when()/unless()/tap(), the query builder, the HTTP client, the validator — are the actual building blocks behind Laravel’s Eloquent, Symfony’s Form Builder, PHP-DI, and every other framework API that developers describe as “pleasant to work with.” Understanding how they’re built lets you design APIs of your own that have that same quality.

 

Frequently Asked Questions

+

What is method chaining in PHP?

Method chaining is a programming technique in PHP that allows multiple methods to be called on the same object in a single statement. It works by returning the current object ($this) from each method.
+

How does method chaining work in PHP?

Method chaining works by returning the current object instance ($this) at the end of each method. This allows the next method to be called immediately on the same object.
+

Why is return $this used in method chaining?

return $this returns the current object instance, enabling subsequent method calls in the chain. Without it, the chain would break after the first method.
+

What are the advantages of method chaining?

Method chaining offers several benefits: Cleaner and more readable code Reduced repetition Fluent interface Easier maintenance Better developer experience
+

What are the disadvantages of method chaining?

Some drawbacks include: Harder debugging for long chains Errors in one method can break the entire chain Reduced readability if overused Not suitable for every class design
+

Can every PHP method be chained?

No. Only methods that return the current object ($this) or another object can be chained. Methods returning void, null, or primitive values cannot continue the chain.
+

What is the difference between method chaining and a fluent interface?

Method chaining is the technique of calling multiple methods in sequence, while a fluent interface is a design pattern that uses method chaining to create readable and expressive APIs.
+

Is method chaining supported in PHP 8?

Yes. Method chaining is fully supported in PHP 8 and later versions. It works the same way as in earlier PHP versions and is commonly used in modern PHP frameworks.
+

Which PHP frameworks use method chaining?

Many popular PHP frameworks use method chaining, including: Laravel Symfony CodeIgniter 4 Doctrine ORM Guzzle HTTP Client PHPUnit
+

Can constructors be used with method chaining?

Yes. After creating an object, you can immediately chain methods if they return $this. Example: $user = (new User()) ->setName('John') ->setEmail('john@example.com') ->save();
+

Is method chaining good for performance?

Method chaining has negligible performance impact. Its primary advantage is improved readability and maintainability rather than execution speed.
+

When should you avoid method chaining?

Avoid method chaining when: The chain becomes excessively long. Methods perform unrelated actions. Intermediate values need to be inspected or logged. Readability is reduced.
+

What is a real-world example of method chaining?

Laravel's query builder is one of the best-known examples: $users = User::where('status', 1) ->orderBy('name') ->limit(10) ->get(); Each method returns the query builder instance, allowing the next method to be chained.
+

Can static methods be used in method chaining?

Yes, if a static method returns an object instance. A common example is: User::query() ->where('active', 1) ->get();
+

Is method chaining considered a best practice in PHP?

Yes, when used appropriately. Method chaining is a widely accepted practice in modern PHP development because it produces cleaner, more expressive code. However, it should be used judiciously to maintain readability and simplify debugging.
Previous Article

How to Increase Web Server Speed in PHP-The Complete Production Optimization Guide

Next Article

Your WordPress Site Has Been Hacked — The Complete Detection, Recovery & Hardening Guide

Write a Comment

Leave a Comment

Your email address will not be published. Required fields are marked *

Subscribe to our Newsletter

Subscribe to our email newsletter to get the latest posts delivered right to your email.
Pure inspiration, zero spam ✨