Static State and Late Static Binding

Understand how PHP's static properties and methods behave, why static state is risky for testability and concurrency, the difference between self:: and static:: resolution (late static binding), and how to use static:: correctly to build fluent APIs and inheritance-safe factory methods.

static in PHP means two related but distinct things: static properties/methods, which belong to the class itself rather than to any instance, and late static binding, a resolution rule that determines which class static:: refers to when called through an inheritance chain. Both are powerful and both are easy to misuse — static state can quietly turn a class into hidden global state that breaks tests and concurrency assumptions, while confusing self:: with static:: is one of the most common subtle bugs in PHP class hierarchies, especially in factory methods and fluent builder APIs that are meant to be subclassed.

Static properties are shared, global-like state

A static property belongs to the class, not to any individual instance — every instance (and any code with access to the class) reads and writes the exact same value. class Counter { public static int $count = 0; public static function increment(): void { self::$count++; } } means every call to Counter::increment(), from anywhere in the codebase, mutates the same shared number. This is functionally equivalent to a global variable, just namespaced under a class name.

The danger is the same danger any global state carries: hidden coupling. Two completely unrelated pieces of code that both call Counter::increment() are secretly coupled through that shared static value, and neither one's behavior can be understood in isolation. In tests, this is especially painful — PHPUnit typically creates a fresh object graph per test, but static properties persist across tests within the same process, so a value set in one test can silently leak into the next, producing flaky, order-dependent test failures that are hard to trace back to their cause.

Static state is not always wrong — a class-level cache of expensive-to-compute, effectively-constant data (like a parsed configuration file) is a reasonable use — but it should be a deliberate, documented choice, not a convenient place to stash mutable data that would otherwise need to be passed around explicitly.

self:: resolves at compile time, static:: at runtime

self:: always refers to the class in which the code is literally written, regardless of which class the method was actually called on. static:: (late static binding, added in PHP 5.3) refers to the class that was actually called at runtime — the class of new static(), or the class named in the call that triggered execution, which is resolved dynamically down the inheritance chain rather than fixed at the point the code is written.

Consider class Base { public static function create(): self { return new self(); } } and class Derived extends Base {}. Calling Derived::create() still returns an instance of Base, not Derived, because self:: inside create() is hardwired to Base at the point that code was written — it doesn't matter that the call arrived via Derived. Replacing new self() with new static() fixes this: Derived::create() then correctly returns a Derived instance, because static:: resolves to whichever class was actually invoked.

The rule of thumb: use self:: when you deliberately want to always refer to the exact class the code is defined in (for instance, accessing a genuinely private implementation detail that subclasses should not override the meaning of), and use static:: whenever a subclass should be able to influence which class gets instantiated or which overridden method gets called — which is almost always the correct choice inside factory methods, fluent builders, and any method meant to remain correct under subclassing.

Fluent APIs need static:: to stay correct through subclassing

A fluent (chainable) API returns $this from each method so calls can be chained: $query->where('active', true)->orderBy('name')->limit(10). When a fluent builder is subclassed — a MysqlQueryBuilder extends QueryBuilder that adds MySQL-specific methods — static factory methods on the base class must use static:: (or, for instance methods, simply return $this, whose runtime type is already correct) so that chaining through the subclass still yields subclass instances rather than silently downgrading to the base class partway through a chain.

This matters most in static creation methods: public static function create(): static { return new static(); } on a base builder class means MysqlQueryBuilder::create() returns a MysqlQueryBuilder, preserving access to its extra methods for the rest of the chain. Had the method used new self(), calling it via the subclass would silently hand back a plain QueryBuilder, and any MySQL-specific method called next in the chain would be a fatal error.

Factory patterns built on late static binding

PHP's static return type (PHP 8.0+) documents this pattern explicitly in the type system: public static function make(): static tells both PHP and IDEs that the method returns an instance of whatever class it was actually called on, not necessarily the class it's defined in. This is the standard way to write a base-class factory method that every subclass can inherit and use correctly without overriding it.

Named constructors — static factory methods with descriptive names instead of an overloaded __construct — combine naturally with static::: User::fromArray($data), User::guest(), Money::fromCents(500) are all clearer at the call site than positional constructor arguments, and when written with new static(...) instead of new self(...), they remain correct if a subclass like AdminUser extends User ever calls them.

self:: vs static:: resolution

Late static binding applies uniformly to new static(), static::method(), and static::$property — all resolve against the class actually invoked at runtime, walking up the inheritance chain from there only if the current class doesn't define its own. The static return type hint (PHP 8.0+) documents 'returns an instance of whatever class this was called on' directly in the type system, which IDEs and static analyzers understand.

class Model
{
    protected static string $table = 'models';

    // WRONG for subclassing: self:: is fixed to Model, always.
    public static function newSelf(): self
    {
        return new self();
    }

    // CORRECT for subclassing: static:: resolves to the called class.
    public static function newStatic(): static
    {
        return new static();
    }

    public static function tableName(): string
    {
        return static::$table; // late static binding applies to static properties too
    }
}

class User extends Model
{
    protected static string $table = 'users';
}

get_class(Model::newSelf());   // 'Model'
get_class(User::newSelf());    // 'Model'  <-- bug: wanted User
get_class(User::newStatic());  // 'User'   <-- correct
User::tableName();             // 'users'  <-- static:: picks up the subclass's override

Code Examples

Even though create() was called via Dog, new self() inside a method defined on Animal always builds an Animal, because self:: is resolved at the point the code is written, not at the point it's called. The returned object silently lacks Dog's bark() method. Expected result: Animal

Switching new self() to new static() makes the factory method inheritance-safe: Dog::create() now correctly returns a Dog, so subclass-specific methods like bark() remain callable. The base class needed no other changes. Expected result: Dog Rex says Woof!

QueryBuilder::make() uses new static() so calling it via MysqlQueryBuilder::make() returns a MysqlQueryBuilder, keeping the entire fluent chain — where(), limit(), toSql() — correctly typed as the subclass all the way through, with zero code duplicated in the subclass for chaining. Expected result: SELECT * FROM t WHERE active = true LIMIT 10

class Animal
{
    public function __construct(public string $name) {}

    public static function create(string $name): self
    {
        return new self($name); // hardwired to Animal
    }
}

class Dog extends Animal
{
    public function bark(): string { return "{$this->name} says Woof!"; }
}

$dog = Dog::create('Rex');
echo get_class($dog) . "\n"; // Animal -- NOT Dog!
// echo $dog->bark(); // Fatal error: Animal has no method bark()
class Animal
{
    public function __construct(public string $name) {}

    public static function create(string $name): static
    {
        return new static($name); // resolves to whichever class was called
    }
}

class Dog extends Animal
{
    public function bark(): string { return "{$this->name} says Woof!"; }
}

$dog = Dog::create('Rex');
echo get_class($dog) . "\n";  // Dog
echo $dog->bark() . "\n";      // Rex says Woof!
abstract class QueryBuilder
{
    protected array $wheres = [];
    protected ?int $limitValue = null;

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

    public function where(string $column, mixed $value): static
    {
        $this->wheres[] = [$column, $value];
        return $this; // runtime type is already correct, no self/static issue here
    }

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

    abstract public function toSql(): string;
}

final class MysqlQueryBuilder extends QueryBuilder
{
    public function toSql(): string
    {
        $sql = 'SELECT * FROM t';
        foreach ($this->wheres as [$col, $val]) {
            $sql .= " WHERE {$col} = " . var_export($val, true);
        }
        if ($this->limitValue !== null) {
            $sql .= " LIMIT {$this->limitValue}";
        }
        return $sql;
    }
}

$sql = MysqlQueryBuilder::make()
    ->where('active', true)
    ->limit(10)
    ->toSql();

echo $sql;

self:: vs static:: through a call chain

self:: is fixed at the point the code is written; static:: is resolved at the point the call actually entered the hierarchy.

Dog::create('Rex')
        |
        v
  Animal::create() runs (method body lives here)
        |
   uses self::   ----> always Animal, regardless of caller
   uses static:: ----> resolves to Dog, the class actually called
        |
  new self(...)   => Animal instance   (bug: loses Dog methods)
  new static(...) => Dog instance      (correct: chain stays typed as Dog)

Important Rules

Static properties are shared class-wide state, equivalent to a namespaced global — prefer them only for genuinely class-level, effectively-immutable data, never as a substitute for passing values explicitly.

self:: always binds to the class where the code is literally written; static:: (late static binding) resolves to whichever class was actually called at runtime.

Use static:: (and the static return type) in any factory method, named constructor, or fluent builder method that a subclass might call, so it keeps returning/operating on the correct subclass.

Instance methods returning $this for fluent chaining don't need the self/static distinction — $this's runtime type is already whatever class was instantiated.

Static state complicates testing: PHPUnit resets object instances between tests automatically, but static properties persist across tests in the same process unless explicitly reset, which is a common source of order-dependent test flakiness.

Where this shows up

Laravel's Eloquent ORM base Model class relies entirely on late static binding: User::create([...]), User::find(1), and User::query() are all static methods defined once on the base Model class, yet each correctly returns/operates on User instances (not generic Model instances) purely because those methods use static:: internally. Fluent query builders across Doctrine, Laravel's query builder, and PSR-7 request/response builders all rely on the same static return-type pattern to keep chained calls correctly typed through subclasses.

class Model { public static function create(array $attrs): self { $instance = new self(); //... return $instance; } } class Product extends Model {} get_class(Product::create([])); // 'Model', not 'Product' -- bug class Model { public static function create(array $attrs): static { $instance = new static(); //... return $instance; } } class Product extends Model {} get_class(Product::create([])); // 'Product' -- correct

self:: in a base-class factory method always builds the base class, silently discarding any subclass-specific behavior a caller expected. Using static:: (and new static()) makes the factory correctly polymorphic across the whole inheritance chain.

class RequestCounter { public static int $count = 0; public function handle(): void { self::$count++; // hidden shared state, mutated by every instance everywhere } } class RequestCounter { private int $count = 0; // instance property, not shared public function handle(): void { $this->count++; } public function getCount(): int { return $this->count; } }

A static counter is shared across every instance and every test in the same PHP process, so one test's counter value leaks into the next and callers can't reason about the count in isolation. An instance property scopes the state to a single object, which is what dependency injection and per-request object graphs assume.

[multiple-choice] A base class defines public static function create(): self { return new self(); } . A subclass Sub extends Base calls Sub::create(). What is returned? A. An instance of Sub, because create() was called via Sub B. An instance of Base, because self:: always resolves to the class where the code is literally written, regardless of how it was called C. A fatal error, because self:: cannot be used in static methods D. null

B. An instance of Base, because self:: always resolves to the class where the code is literally written, regardless of how it was called — self:: is bound at compile time to the class the code is defined in (Base), not the class that made the call. To get a Sub instance back, the method needs to use new static() instead of new self().

[multiple-choice] Why is static state (a public static property) risky for automated tests? A. PHPUnit cannot read static properties at all B. Static properties persist across tests within the same PHP process, so a value set in one test can leak into another and cause order-dependent flakiness C. Static properties are always private by default and inaccessible D. Tests run in a completely separate process for every single assertion, so this is never actually a problem

B. Static properties persist across tests within the same PHP process, so a value set in one test can leak into another and cause order-dependent flakiness — Because static properties are shared class-wide state that outlives any single object, and PHPUnit typically runs many tests in one process, a value mutated by one test can still be present when the next test runs, producing bugs that only appear depending on test execution order.

[multiple-choice] In a fluent builder base class, why does a static factory method typically need static:: while an instance method like where() returning $this does not? A. Instance methods can never be inherited, so the issue doesn't apply to them B. $this already carries the correct runtime class at the point it's returned, whereas self::/static:: inside a static method must explicitly choose which class to instantiate C. static:: is only valid inside static methods, so instance methods are exempt automatically D. Fluent builders never use static factory methods in practice

B. $this already carries the correct runtime class at the point it's returned, whereas self::/static:: inside a static method must explicitly choose which class to instantiate — $this refers to the actual object that exists, whose class is already correct regardless of where the method is defined. A static method has no $this yet — it must decide which class to construct, and only static::/new static() picks the class that was actually called rather than the class the code happens to be written in.

Write an abstract class Shape with a static factory public static function blank(): static { return new static(); } and an instance method describe(): string (abstract). Create two subclasses, Circle and Square, each implementing describe() differently. Call Circle::blank()->describe() and Square::blank()->describe() and confirm each returns output specific to its own subclass, proving static:: resolved correctly in both cases.

abstract class Shape { public static function blank(): static { return new static(); } abstract public function describe(): string; } class Circle extends Shape { public function describe(): string { return 'I am a circle'; } } class Square extends Shape { public function describe(): string { return 'I am a square'; } } echo Circle::blank()->describe(); // I am a circle echo Square::blank()->describe(); // I am a square

Nothing in Shape::blank() needs to change between the two subclasses — that's the entire point of late static binding; each subclass gets correct factory behavior for free by inheriting the base method unmodified.