PHP Static Singleton — Cache Contaminated by Test Order
A static property $userCache persisted across tests, causing order-dependent failures.
- Static members belong to the class, not to objects — shared across all instances.
- self:: resolves to the class where the code is written; static:: resolves to the child class at runtime via Late Static Binding.
- Legitimate uses: pure utility functions, named constructors, singletons when genuinely needed.
- Avoid static for dependencies that need swapping in tests — use dependency injection.
- Performance: static calls are ~5% faster than instance calls due to no constructor overhead, but the difference rarely matters at scale.
Imagine a scoreboard at a basketball game. Every player on the court is their own person (their own object), but there's only ONE scoreboard that everyone shares. You don't need to ask a specific player what the score is — the scoreboard belongs to the game itself. Static properties and methods work exactly like that scoreboard: they belong to the class itself, not to any individual object created from it.
Most PHP developers learn static methods early and then either overuse them everywhere or avoid them entirely out of confusion. Neither extreme is right. Static members are one of PHP's most misunderstood features — powerful in the right context, a liability in the wrong one. Understanding them deeply separates developers who write maintainable code from those who create tightly-coupled spaghetti.
The problem static solves is straightforward: sometimes data or behaviour genuinely belongs to a concept (the class), not to any specific instance of it. A database connection counter, a registry of loaded plugins, a utility method that formats a currency string — none of these need an object to exist first. Forcing them into instance methods means creating throwaway objects just to call a function, which wastes memory and communicates the wrong intent to anyone reading your code.
By the end of this article you'll know exactly how static properties and methods work under the hood in PHP, the difference between self:: and static:: (this one trips up senior devs), the legitimate real-world patterns where static shines, and the exact pitfalls that turn static code into a testing nightmare. You'll be able to make deliberate, defensible choices — not guesses.
What Static Actually Means — Class-Level vs Instance-Level
Every time you call new in PHP, the engine allocates fresh memory for that object and populates its properties with their default values. That object is completely independent — changing User()$userA->name has zero effect on $userB->name. This is instance-level state, and it's the backbone of OOP.
Static is the opposite deal. A static property lives in the class definition itself, not inside any object. PHP allocates it exactly once for the lifetime of the request, and every object of that class — plus any code that references the class directly — shares the exact same value. There's no copying, no per-object version.
A static method is similar: it's a function attached to the class rather than to an instance. Because there's no instance involved, PHP won't give you a $this variable inside a static method. Trying to use $this in a static context is a fatal error. Instead, you use self:: or static:: to reference the class itself.
This distinction isn't just academic. It changes how you reason about your code. Instance methods say 'do this TO an object'. Static methods say 'do this WITH this class'. Getting that mental model right is half the battle.
self:: vs static:: — The Late Static Binding Trap
Here's where most intermediate developers have a gap in their knowledge: self:: and static:: look almost identical but behave completely differently when inheritance is involved.
self:: is resolved at compile time. It always refers to the class where the method was physically written, regardless of which child class called it. Think of it as hard-coded — it doesn't care about the runtime context.
static:: uses Late Static Binding (LSB), which means PHP resolves it at runtime to whichever class actually triggered the call. If a child class calls an inherited static method, static:: will point to the child class, not the parent.
This matters enormously in factory methods and singleton patterns. If you write a base Model class with a static::create() factory and use self::, every subclass's will silently return a base create()Model object instead of the correct subclass. That bug is invisible until you try to call a child-class method on the returned object.
The rule of thumb: use static:: in any static method you expect subclasses to inherit. Use self:: only when you intentionally want to lock the reference to the current class — for example, in a constant lookup where inheritance shouldn't change the value.
find() method. When a child UserRepository inherited it, all find() calls returned Repository objects instead of UserRepository objects. The bug went unnoticed for two weeks because only Repository methods were used. When UserRepository added a custom method, production crashes followed.Two Legitimate Real-World Patterns for Static in PHP
Static gets a bad reputation largely because it's overused. But there are genuine, well-established patterns where it's the right tool.
Pattern 1 — The Singleton (use sparingly): When your application genuinely needs exactly one instance of something — a logger, a config loader, a database connection pool — the Singleton pattern uses a static property to hold that single instance and a static method to retrieve it. The static property ensures only one instance is ever stored, regardless of how many times you call getInstance().
Pattern 2 — Named Constructors / Static Factory Methods: PHP constructors are limited: you can only have one __construct(). Static factory methods solve this elegantly. Money::fromCents(150) and Money::fromFloat(1.50) are far clearer than trying to overload a constructor with optional parameters and type checks. Each factory method is static because you need to call it before any object exists yet.
Both patterns are about clarity of intent. When you see Logger::getInstance(), you instantly know there's one logger. When you see DateRange::fromString('2024-01-01/2024-12-31'), you know exactly what kind of construction is happening. Static, used this way, makes your API more expressive — not less maintainable.
Why Static Can Hurt You — Testability and Hidden State
Static is seductive because it's convenient. DatabaseConnection::query($sql) is easier to type than injecting a $db dependency everywhere. But convenience now often means pain later, and static is one of the most common sources of untestable code in PHP projects.
The core problem: static calls are hard-coded dependencies. When OrderProcessor calls TaxCalculator::calculate($amount) directly, you cannot swap TaxCalculator for a test double without either modifying OrderProcessor or reaching for PHP-specific mocking tricks that only work in specific test frameworks. Unit tests should be isolated — that's their entire value.
Static properties compound this by introducing hidden global state. A test that runs fine in isolation can fail when run after another test that left a static property in a modified state. These bugs are notoriously hard to track down.
The litmus test for static is simple: Is this behaviour or data genuinely context-free? A string formatter that trims whitespace and capitalises words needs no context — static is fine. An order processor that calculates prices needs a tax strategy, a discount service, and locale awareness — those are dependencies that should be injected, not statically called. When you're unsure, ask 'do I need to swap this out in a test?' If yes, don't use static.
shouldReceive, but this replaces the class globally — it's stateful and can leak between tests. A better approach is to refactor to instance methods and use constructor injection.Refactoring Static Dependencies: A Practical Migration Guide
A common real-world scenario: you inherit a legacy PHP codebase where half the business logic lives in static methods. OrderService::calculateTotal($order) is called from controllers, commands, and even other static utilities. Your task is to make OrderService testable without rewriting the entire application in one go.
The safe migration strategy is the Tuckman Refactoring Pattern:
- Wrap the static call in a non-static wrapper class that delegates to the static method.
- Implement the wrapper as an interface, and create a production implementation that calls the static method.
- Replace calls to the static method with calls through the wrapper interface.
- Inject the wrapper via constructor or setter method.
- Replace the static implementation with an instance implementation once all callers use the interface.
This approach lets you swap the static logic without changing every consumer at once. The wrapper class becomes the seam where you can inject a test double.
In modern PHP frameworks, the service container handles this cleanly. Laravel's App::bind() or Symfony's $container->set() let you rebind the implementation in tests. The static call becomes a one-line binding change instead of a month-long refactor.
OrderService::calculateTotal also calls TaxService::getRate(), you'll need to wrap that too. This is why static-heavy codebases feel rigid — the static binds everything together.Static Singleton Cache Contaminated by Test Order
UserRepository static property self::$userCache cached user data during one test and was never reset. Subsequent tests that queried the same user ID received stale data from the cache instead of the fresh database state.resetStaticProperties() method in the test base class called in setUp() that nulled out all relevant static caches. Better solution: replaced the static cache with an instance-level cache injected via constructor so each test gets a fresh instance.- Static properties persist across test executions — always reset them in setUp or tearDown.
- Static state is the leading cause of flaky tests in PHP codebases.
- Prefer instance-scoped caching when the container can manage lifecycle.
var_dump() at the start of each test to see if property holds residual state from previous test. Run phpunit --order-by=defects to isolate.echo spl_object_id($instance) to confirm same object reused. Reset singleton after each job if volatile.new self(), replace with new static(). The bug is invisible until you call a child-only method on the returned value.self:: to static:: in all factory methods that subclasses override.Key takeaways
Common mistakes to avoid
3 patternsUsing self:: in inheritable factory methods
Storing mutable application state in static properties
Calling static methods via an object instance ($obj::method() or $obj->method())
Interview Questions on This Topic
What is the difference between self:: and static:: in PHP, and when would using self:: cause a bug in a class hierarchy?
self(), a child class calling that factory will get a base class object, not a child class object. This leads to fatal errors when calling child-specific methods on the returned object. Fix: use new static() instead.Frequently Asked Questions
That's OOP in PHP. Mark it forged?
5 min read · try the examples if you haven't