Counters, Registries, and Other Class-Level State
Some state belongs to no single object. "How many Connection instances has this process opened?" is a question about the class, not about any one connection — and the natural place to answer it is a static property:
final class Connection
{
private static int $opened = 0;
public function __construct()
{
self::$opened++;
}
public static function openedCount(): int
{
return self::$opened;
}
}
$a = new Connection();
$b = new Connection();
echo Connection::openedCount(); // 2 — one counter for the whole class
$a->openedCount(); // also works — more on that below
self::$opened is not copied into each instance. There is exactly one $opened for the class, shared by every object and by every caller of Connection::openedCount(). Instance properties, by contrast, are per-object slots allocated at construction. That single-vs-many distinction is the entire concept, and it explains why a static property is the right home for process-wide state: instance counters, configured defaults, memoized lookups, or a small connection pool registry.
Visibility modifiers apply to statics exactly as they do to instance members — private static is the default you want, because shared mutable state that anyone can write is a global variable wearing a class costume:
Connection::$opened = 0; // Error: Cannot access private static property
Keeping the counter private and exposing openedCount() as a static method is the encapsulated form. Note the call syntax: Connection::openedCount() invokes it on the class with ::, and — a genuinely PHP-specific detail — $a->openedCount() also works for a static method called on an instance. The reverse is not true: Connection::someInstanceMethod() throws Error: Non-static method Connection::x() cannot be called statically in PHP 8 (older PHP merely deprecated it, which is why legacy code still contains the pattern).
The shared-state knife cuts both ways, which is why static properties deserve suspicion. One counter for Connection means every test in the suite sees the previous test's count unless you reset it; one registry means every consumer mutates the same array. PHP 8.1's readonly explicitly refuses to make static properties readonly for this reason, and the engine cannot scope a static to a request lifecycle for you — that ledger is settled in the last chapter of this topic. Use static state when it is genuinely global and effectively immutable after boot; reach for an instance when anything must vary per object or per test. When a child class extends a class with static state, note what it does not get: self::$opened in the parent's methods counts one pool for the parent, even when called on child instances — and untangling which class a static refers to is precisely what static:: and late static binding solve: self versus static is the very next chapter.
A quick recap ties the section together. Static properties honor private, protected, and public exactly like instance properties, so the recommended shape is private state plus a public static reader — Connection::openedCount() in the example — which keeps the counter honest without handing out the mutable slot. Every instance shares that one slot, meaning the counter persists across objects within a request and across requests served by the same process; under PHP-FPM statics reset per request because each request boots fresh state, while in long-running workers (RoadRunner, Swoole, queue consumers) they live for the worker's lifetime. Knowing which runtime you deploy on is therefore part of reasoning about static state.
This topic continues the class-composition line from Polymorphism and Method Overriding: where that topic gave each instance its own behavior, statics add a second tier that belongs to the class as a whole — and the two tiers follow different rules about $this, sharing, and testing.
Why a Static Method Cannot See $this, and What static:: Adds
A static method runs with no instance in scope. Connection::openedCount() executes the body without any $this, because the call was made on the class, not on an object. That is why this is a hard error, not a warning:
final class Invoice
{
public function __construct(private float $total) {}
public static function summary(): string
{
return $this->total; // Error: Using $this when not in object context
}
}
Invoice::summary(); // fatal at the $this access
The symmetry rule: a static method may touch only static members; an instance method may touch both. self::$opened inside a static method is fine because the property is class-level; $this->total is not, because there is no $this to point at. And in PHP 8 the reverse misuse is also a fatal — calling a non-static method statically throws Error rather than PHP 5's deprecated-but-working pseudo-mode. If you need the instance data, construct or accept an instance.
When a static method must decide which class's statics to use inside an inheritance chain, PHP offers two keywords with different scopes:
class Logger
{
protected static string $channel = 'base';
public static function who(): string
{
return self::channel . '/' . static::channel;
}
}
final class AppLogger extends Logger
{
protected static string $channel = 'app';
}
Logger::who(); // 'base/base'
AppLogger::who(); // 'base/app' — static:: followed the CALLED class
self:: resolves to the class where the code is written; static:: — late static binding, since PHP 5.3 — resolves to the class the method was called on. That difference is what makes a base-class static method configurable per subclass, and it is why the factory idiom uses static in its return type:
class Model
{
public static function find(int $id): static
{
// returns User::find(1) as a User, Post::find(2) as a Post
return new static(/* hydrated from a row */);
}
}
new static inside the base class builds an instance of the called class — one shared hydration implementation for the whole model family. The pair worth memorizing: self = "here", static = "called on"; new self = instance of this class, new static = instance of the called class.
One caution about static return types and overrides: a child overriding find(): static must keep returning the called-class type; dropping to self narrows the contract and fatals. And because late static binding keys off the called class, marking classes final does not weaken it — it just fixes who the callee can ever be. The last static question is not mechanical but economic: static calls are convenient and globally reachable, and that reachability is exactly what makes them expensive to test.
Late static binding has one more visitor worth knowing: inside an inherited static method, static::class names the called class, which is the property that makes per-subtype factories and serialization hints work. Its limits deserve equal time: static:: cannot see private members of the called class when the code lives in a parent (only protected and public flow through), and an abstract static method would have no single implementation to bind, which is why interfaces forbid them outright. If you remember one operational rule, make it this: when a base-class static method needs to vary per child, static:: is the mechanism; when it must not vary, self:: is the enforcement.
Factories, Helpers, and the Testability Cost of Static Calls
Not every :: is a design smell. Three uses of statics are uncontroversial in modern PHP:
final class Money
{
public function __construct(public readonly int $cents, public readonly string $currency) {}
public static function fromString(string $s): self // named factory
{
// '12.34' -> new self(1234, 'EUR')
}
public static function zero(string $currency): self // named constructor
{
return new self(0, $currency);
}
}
final class UrlSlug
{
public static function make(string $title): string // pure helper
{
return strtolower(trim(preg_replace('/[^a-z0-9]+/i', '-', $title), '-'));
}
}
Named factories (Money::fromString) document intent and can return any subtype; pure helpers (UrlSlug::make) are functions grouped near their data; constants and immutable value objects round out the list. In all three, there is no decision to make at runtime — you never need a different UrlSlug behavior in a test, so the static call binds nothing you would want to swap.
The cost arrives the moment a static call makes a decision that a test would want to override:
final class ReportGenerator
{
public function render(): string
{
$rows = Database::fetch('SELECT ...'); // static call — what does the test hit?
return PdfEngine::build($rows);
}
}
Database::fetch cannot be stubbed: PHP resolves the class at the call site, there is no seam, and PHPUnit cannot intercept it. Tests must hit a real database, spin up a transaction, or run the code through a global-state back door — all slow, all fragile. The replacement is the injected collaborator:
final class ReportGenerator
{
public function __construct(private QueryRunner $db, private PdfBuilder $pdf) {}
public function render(): string
{
return $this->pdf->build($this->db->run('SELECT ...'));
}
}
// test: new ReportGenerator($fakeRunner, $fakePdf)
The same shape runs in production unchanged; the test substitutes a fake implementing the same interface. This is the practical line: statics for construction and calculation, instances for collaboration. The notorious anti-pattern on the wrong side is the Singleton — Database::instance() — which is a static property pretending to be architecture: it hides a global, serializes access to one shared connection, and turns every consumer into a hard dependency on process-wide state. Modern PHP solves the "one configured instance, many consumers" problem with a DI container that builds objects and hands them to constructors, keeping the wiring in one file and the classes pure.
There is a migration path, too, for code you inherited: a static method can delegate to an injected instance internally (the Database::fetch() wrapper calls a singleton behind the scenes), so you can introduce interfaces at the seams and delete the wrapper last. Whichever side of the line a class lands on, the error-handling story is the same, and it starts one topic later — first you need the half-implemented parents that let whole families share a static factory: Abstract Classes and Contracts.
One more migration tool for teams stuck on legacy facades: an interface over the static surface. Define a Clock interface with now(), wrap the static Time::now() behind an adapter, and let the container inject the adapter; call sites move from Time::now() to $this->clock->now() file by file as they are touched. The pattern — wrap, inject, unwire — turns a big-bang rewrite into a series of reviewable diffs, and each step leaves the suite green. The destination is worth it: constructors that declare every dependency, tests that fake them in one line, and no hidden ordering between a class's static initializer and the code that depends on it.
Quiz
1. Two Connection instances have been created and Connection::$opened is a private static counter incremented in the constructor. What does Connection::openedCount() return?
2. What happens in PHP 8 when you call a non-static method statically, e.g. Invoice::summary() where summary() uses $this?
3. Logger defines who() returning self::channel . '/' . static::channel; AppLogger extends Logger and overrides $channel. What does AppLogger::who() print?
4. Which use of a static call is LEAST likely to hurt testability?
Flashcards
Question
Static vs instance members: where does each live and how is each accessed?
Click to reveal answer
Answer
Instance properties/methods are per-object slots, accessed with -> and needing $this. Static properties/methods belong to the class: one shared copy, accessed with :: (though calling a static method on an instance also works). Statics take visibility modifiers like instance members.
Question
Why can't a static method use $this, and what does PHP 8 do when a non-static method is called statically?
Click to reveal answer
Answer
A static call has no instance context, so $this is an Error ('Using $this when not in object context'); statics may touch only static members. In PHP 8, calling a non-static method statically is a fatal Error (PHP 5 deprecated it and let it limp along).
Question
self:: versus static:: (late static binding), and new self versus new static
Click to reveal answer
Answer
self resolves to the class where the code is written; static resolves to the class the method was called on — making base static methods configurable per subclass. new self builds an instance of the declaring class; new static builds the called class, which is what makes a shared hydrating factory return each model's own type.
Question
When are static members the right tool, and when are they a testability problem?
Click to reveal answer
Answer
Right: genuinely global, effectively immutable state (counters at boot), pure helpers, named factories on value objects. Problem: statics that make runtime decisions a test must override (Database::fetch, Cache::get) — they cannot be stubbed because PHP resolves the class at the call site. Rule: statics for construction and calculation, instances for collaboration.
Question
What is wrong with the Singleton pattern in modern PHP, and what replaces it?
Click to reveal answer
Answer
Database::instance() is a static property disguised as architecture: it hides a global, pins one shared connection for the process, and hard-wires every consumer to process-wide state that tests cannot swap. A DI container builds one configured instance and injects it through constructors, keeping wiring in one file and classes testable.
Revision Notes
Key Takeaways
- 1. A static property is one shared slot per class — right for genuinely global state, and the reason counters survive across every instance and every test
- 2. Static methods have no $this; in PHP 8, calling an instance method statically is an Error, not a deprecation
- 3. self:: is 'here' and static:: is 'called on' — late static binding is what lets one base static method serve per-subclass configuration
- 4. Statics for construction and calculation (factories, pure helpers); injected instances for collaboration anything a test must fake
- 5. Singletons hide globals; a DI container delivers the same 'one configured instance' without hard-wiring process-wide state into consumers
Interview Tips
- • "When is a static property appropriate?" — class-level state that is genuinely shared and stable after boot (counters, memoized immutable data); name the test-visibility cost as the reason it is not the default
- • "self vs static in PHP?" — self is the declaring class, static is the called class via late static binding; back it with the find(): static factory returning each subclass's own type
- • "Why can't I call my instance method statically?" — no object context means no $this; PHP 8 made it an Error (it was merely deprecated in 5.x), and the fix is to pass or construct an instance
- • "How would you make Database::fetch testable?" — extract a QueryRunner interface, inject it through the constructor, fake it in tests; mention the wrap-and-delegate migration path for legacy static facades
Cheat Sheet
Static Properties and Methods
Class-level state:
final class Connection
{
private static int $opened = 0;
public function __construct() { self::$opened++; }
public static function openedCount(): int { return self::$opened; }
}
Connection::openedCount(); // one counter for the whole class, all instances share it
Statics take visibility like instance members; keep shared statics private and expose static readers.
Scope rules:
| Rule | Behavior |
|---|---|
static method + $this |
Error — no instance context |
| non-static method called statically | Error in PHP 8 (deprecated in PHP 5) |
self:: |
the class where the code is written |
static:: |
late static binding: the class the method was called on |
new self / new static |
instance of the declaring / called class |
class Model
{
public static function find(int $id): static { return new static(/* row */); }
}
The testability ledger:
| Static use | Verdict |
|---|---|
Named factories (Money::fromString), pure helpers (UrlSlug::make) |
fine — no runtime decisions |
Database::fetch, Cache::get, Config::get in logic |
inject an instance behind an interface |
Singleton X::instance() |
hidden global — replace with a DI container |
Migration: wrap the static, delegate to an injected instance internally, delete the wrapper last.