Why a static property in the base class is shared by every subclass
At first glance static properties look like a convenient place for class wide state, such as a counter or a registry. Once inheritance enters the picture, though, a pitfall appears that regularly surprises experienced developers: a static property declared in the base class is not duplicated per subclass, it is shared by all subclasses unless explicitly redeclared. This article walks through the problem with concrete examples and describes ways to avoid it reliably.
Table of Contents
- 1. The core problem: one property, many classes
- 2. How inheritance technically works for static properties
- 3. Common bug: counter pattern across multiple subclasses
- 4. Common bug: registry pattern with a shared property
- 5. Solution 1: redeclare the property in the subclass
- 6. Late static binding as a partial solution
- 7. Why late static binding alone is not enough
- 8. Impact on testability
- 9. Practical recommendation: when static properties actually make sense
- 10. Summary
- 11. FAQ
1. The core problem: one property, many classes
A static property is not stored per object instance, it is stored once per class declaration. That is its actual purpose: state shared across every instance of a class. With inheritance, however, that exact mechanism leads to a surprise that at first glance contradicts object oriented intuition: if a class B extends a class A and does not redeclare the static property itself, A and B share the same storage location for that property.
Concretely this means a write through B::$value changes the same value visible through A::$value, and vice versa. For developers coming from languages with strict instance per class behavior, this is counterintuitive, because ordinary, non static properties create their own independent storage for every instance. For static properties this isolation only applies between different class declarations, not between a base class and a subclass that has no declaration of its own.
2. How inheritance technically works for static properties
Technically, PHP allocates exactly one storage slot for every class that declares a static property. A subclass that does not declare the same property itself merely inherits access to that already existing slot in the base class, it does not create a new one. Only once a subclass explicitly redeclares the same property under the same name does it receive its own, independent slot, which from that point on exists separately from the base class's slot.
This behavior is easy to follow by looking at PHP's internal class table: as long as no redeclaration happens, the subclass's property definition simply points to the same memory location as the one in the base class. This is consistent with the general PHP principle that inheritance shares behavior and access by default unless explicitly overridden, but here it applies to the underlying storage itself, not just to methods.
3. Common bug: counter pattern across multiple subclasses
A particularly frequent case is a counter meant to track how many instances have been created. If the static counter property is declared in a shared base class, it does not count instances per subclass, it counts the total across every subclass, which is rarely the actual intent. A developer expecting a separate count per subclass ends up puzzled by values that look far too high, because they incorrectly add up every subclass together.
The example below shows two vehicle classes inheriting from a shared base class. Both are meant to count independently how many instances of each were created. Without redeclaring the property, though, every creation lands in the same shared counter, regardless of which specific subclass was instantiated.
<?php
declare(strict_types=1);
class Vehicle
{
protected static int $instanceCount = 0;
public function __construct()
{
static::$instanceCount++;
}
}
final class Car extends Vehicle
{
}
final class Truck extends Vehicle
{
}
new Car();
new Car();
new Truck();
// Surprise: both values are identical, because Car and Truck
// share the same storage slot from the base class
var_dump(Car::$instanceCount); // int(3)
var_dump(Truck::$instanceCount); // int(3)
4. Common bug: registry pattern with a shared property
Another widespread example is a registry pattern where different subclasses are each supposed to maintain their own collection of registered objects. If the internal array is declared as a static property in the base class, registrations from every subclass accidentally end up in the same shared collection. A plugin system, for example, where each plugin category expects its own registry, ends up unintentionally mixing entries from different categories this way.
Such bugs are especially treacherous because they often go unnoticed in small tests, as long as only one subclass is used per test run. Only once several subclasses are active together in the same process, for example in a longer running application or a test suite with many test classes, does the shared state become visible, often as a hard to reproduce failure that depends on execution order.
5. Solution 1: redeclare the property in the subclass
The most direct fix is to explicitly redeclare the same static property under the same name in every subclass. This gives each subclass its own, independent storage slot, and access through the respective class name stays fully isolated from the others. This solution is simple but requires discipline: every new subclass must remember the redeclaration, otherwise the pitfall reappears.
In practice it is worth adding a clear comment to the redeclaration explaining why it is needed, so later developers do not accidentally remove the seemingly redundant line. A PHPStan or Psalm check verifying that every subclass with its own state requirement actually has its own declaration can further automate this discipline.
<?php
declare(strict_types=1);
class Vehicle
{
protected static int $instanceCount = 0;
public function __construct()
{
static::$instanceCount++;
}
}
final class Car extends Vehicle
{
// Creates its own storage slot, independent from Vehicle
protected static int $instanceCount = 0;
}
final class Truck extends Vehicle
{
protected static int $instanceCount = 0;
}
new Car();
new Car();
new Truck();
var_dump(Car::$instanceCount); // int(2), correctly isolated
var_dump(Truck::$instanceCount); // int(1), correctly isolated
6. Late static binding as a partial solution
In the previous example, notice that static::$instanceCount was used instead of self::$instanceCount. That is late static binding: static:: refers at runtime to the class that was actually called, not the class where the method was originally defined. Without late static binding, self::$instanceCount inside the base class constructor would always access Vehicle's property, even if Car::$instanceCount was declared separately.
It is important that late static binding alone does not solve the shared state pitfall, it only controls which storage slot is addressed at access time, it does not create a new slot. Only the combination of static:: for access and an explicit redeclaration of the property in the subclass produces the desired, isolated behavior. Late static binding itself is covered in depth in its own dedicated article.
7. Why late static binding alone is not enough
A common misunderstanding is assuming static:: alone already provides isolated state per subclass. Without redeclaring the property, static::$instanceCount in Car and in Truck still points to the same storage slot of the base class, simply because no second slot exists to point to. Late static binding only changes which class name is used in code to access the property, not how many physical storage locations exist.
This distinction between access mechanism and storage location is the core of the misunderstanding. Once both concepts are clearly separated, it becomes clear that isolated state per subclass needs two prerequisites: its own storage slot through redeclaration, and a dynamic access mechanism through static:: instead of self::, so that inherited methods from the base class actually address the correct, subclass specific slot.
8. Impact on testability
Shared static state becomes especially problematic in test suites, because PHPUnit test classes run in the same process by default. A test that mutates a static property of a base class can thereby influence the outcome of a completely unrelated, later executed test that happens to use a subclass of the same base class. Such dependencies on test order rank among the hardest failure patterns in testing practice.
A robust test suite should therefore either avoid static state in base classes entirely, or reset it explicitly in every test, for example in a setUp or tearDown method. Even better is replacing shared state with dependency injection from the start, which removes the problem structurally, since state no longer exists at the class level but at the instance level.
9. Practical recommendation: when static properties actually make sense
Static properties generally make sense when state is deliberately meant to be shared class wide rather than per instance, for example in a genuine singleton or a constant like cache that should never vary per subclass. As soon as inheritance is combined with a desire for subclass specific state, though, a static field is almost always the wrong choice.
In most cases the more robust alternative is a dependency injection container or an explicit instance variable managed per object or per subclass singleton, rather than relying on the implicit, often confusing inheritance mechanism of static properties. Where static properties remain unavoidable, redeclaring them in every subclass should become a firm convention, ideally backed by an automated check in the code review process.
| Scenario | Property redeclared? | Result | Recommendation |
|---|---|---|---|
| Base class, self:: access | No | All subclasses share one slot | Avoid for subclass specific state |
| Base class, static:: access | No | Still one shared slot | Late static binding alone does not fix it |
| Subclass with own declaration | Yes | Own, isolated slot | Correct pattern for counter/registry |
| Singleton pattern | Yes, deliberately in base class | Intentionally shared state | Fits the purpose of static |
| Test suite with multiple subclasses | Depends on the code | Order dependent failures possible | Reset state in setUp or use DI |
Mironsoft
PHP modernization, code quality, and legacy refactoring
Grown PHP code nobody wants to touch anymore?
We modernize PHP codebases to current language standards, introduce static analysis and coding standards, and refactor legacy code step by step without endangering live operations.
Legacy Refactoring
Modernize grown PHP code in a structured, low-risk way.
Establishing Code Quality
Anchor PHPStan, coding standards, and CI checks sustainably in the team.
Version Upgrades
Plan and execute PHP major version upgrades safely, without downtime.
10. Summary
Static Properties and Inheritance
Core mechanism
A static property belongs to the declaring class, not automatically to subclasses.
Common bug
Counter and registry patterns unintentionally accumulate across every subclass.
Solution
Explicitly redeclaring the same property in each subclass creates its own slot.
LSB limit
Late static binding only controls access, it does not create new storage.