Testability as an Architecture Principle: Testable PHP Design
AI generated
<?php
8.4
PHP 8.4 · Clean Code · Testability · Architecture
Testability as an Architecture Principle
Testable design in PHP 8.4, independent of PHPUnit or any other test runner

Testability is not created when the tests are written, but when the classes themselves are designed. Injecting dependencies, isolating side effects, and avoiding static calls builds code that can be tested without detours, no matter which test framework ends up being used. This article shows the design principles behind testable PHP code, beyond assertions and test runners.

18 min read Dependency Injection · Seams · Pure Functions PHP 8.4 · Framework-independent

1. Why Testability Is an Architecture Decision, Not a Tool Problem

Many teams say "we test with PHPUnit" and believe that settles the question of testability. In reality, testability is not decided when the tests are written, but much earlier: during class design, method decomposition, and the question of how dependencies get into a class. A test runner cannot enforce a structure that is not already present in the production code. Testability is therefore a property of the architecture, not of the tool that eventually calls assertEquals.

If testability is only demanded after the fact, usually once the first tests need to be written, the real scope of the problem becomes visible. A class that opens a database connection internally via new, reads a file, or queries the system clock cannot be tested in isolation without refactoring. The effort required to retrofit such a design for testability often exceeds the effort of designing it with testable design in mind from the start. Every line of code written without regard for testability becomes technical debt that comes due the moment the first test is attempted.

Testable design does not mean introducing an abstraction for every conceivable case. It means making deliberate decisions at the points where dependencies, side effects, and global state arise. The following sections cover exactly these decision points: dependency injection, seams, pure functions, and the deliberate handling of time, randomness, and I/O. None of these points has anything to do with a specific test framework, they concern the structure of the production code itself.

2. Dependency Injection as the Foundation of Testability

Dependency injection is the most fundamental prerequisite for testability. As soon as a class creates its own dependencies instead of receiving them from outside, the behavior of those dependencies can no longer be influenced in a test. A constructor that accepts a repository, an HTTP client, or a clock as a parameter makes it possible to substitute a double or a simple stub in the test. Without this injection, the only options are global state or patching internal calls, both stopgaps that fail to establish real testability.

PHP 8.4 makes dependency injection more compact than ever. With constructor property promotion, manually assigning parameters to properties disappears entirely, injection becomes a pure declaration in the constructor head. What matters for testability, though, is less the brevity of the syntax than the discipline behind it: every dependency a class needs must be explicitly visible in the constructor. A new buried in the middle of a method, hidden among business logic, escapes any control from outside and turns exactly that method into a testability blocker, no matter how small the rest of the class is.

The difference between a hard-coded dependency and an injected interface is most visible in a direct comparison of two versions of the same class. The following example first shows the antipattern, then the testable alternative with an injected ClockInterface.


<?php

declare(strict_types=1);

namespace App\Order;

/**
 * Calculates whether an order is still eligible for a discount.
 * Untestable: the DateTime dependency is created inline.
 */
final class DiscountCalculator
{
    public function isEligible(Order $order): bool
    {
        // Hard-coded dependency: cannot be replaced in a test
        $now = new \DateTimeImmutable();

        $deadline = $order->getCreatedAt()->modify('+7 days');

        return $now <= $deadline;
    }
}

<?php

declare(strict_types=1);

namespace App\Order;

/**
 * Calculates whether an order is still eligible for a discount.
 * Testable: the current time is injected via ClockInterface.
 */
final readonly class DiscountCalculator
{
    public function __construct(
        private ClockInterface $clock,
    ) {
    }

    public function isEligible(Order $order): bool
    {
        $now = $this->clock->now();

        $deadline = $order->getCreatedAt()->modify('+7 days');

        return $now <= $deadline;
    }
}

interface ClockInterface
{
    public function now(): \DateTimeImmutable;
}

3. Seams: Where Can Behavior Be Swapped?

Michael Feathers coined the term seam in "Working Effectively with Legacy Code" for a place in the code where behavior can be changed without editing the code at that spot itself. A seam is thus the concrete, technical answer to how testability is actually achieved: not through abstract principles, but through concrete points in the code where a test can intervene. In object-oriented PHP, the most important seam type is the object seam, realized through interfaces and dependency injection.

Particularly critical for testability are global functions that are non-deterministic or touch external resources: time(), rand(), file_get_contents(), curl_exec(). These functions cannot easily be replaced with a test double in PHP because they are not objects, but free functions in the global namespace. The established approach is to hide each of these functions behind a thin interface layer, a so-called humble object. The class that calls curl_exec directly is deliberately kept thin and simple, containing almost no logic of its own and needing no test at all. All business logic that builds on the result of this call instead works against the interface and is therefore fully testable.

The following example shows this seam using an HTTP call: an HttpClientInterface fully encapsulates curl_exec, so that every class that needs to make HTTP requests programs against the abstraction instead of the concrete function.


<?php

declare(strict_types=1);

namespace App\Http;

/**
 * Seam around curl_exec: business logic depends only on this interface.
 */
interface HttpClientInterface
{
    /**
     * @param array<string, string> $headers
     */
    public function get(string $url, array $headers = []): HttpResponse;
}

/**
 * The only class in the application allowed to call curl_exec directly.
 * Kept intentionally thin, no business logic, no test needed here.
 */
final class CurlHttpClient implements HttpClientInterface
{
    public function get(string $url, array $headers = []): HttpResponse
    {
        $handle = curl_init($url);
        curl_setopt($handle, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($handle, CURLOPT_HTTPHEADER, $headers);

        $body = curl_exec($handle);
        $status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);
        curl_close($handle);

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

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

4. Pure Functions vs. Side Effects: Small Deterministic Building Blocks

A pure function always returns the same output for the same inputs and changes no state outside its own scope. This concept of referential transparency is central to testability, because a test for a pure function needs no setup, no mocks, and no teardown. You call the function with input values and compare the result to the expected value, done. No network access, no database connection, no timestamp that could differ between two test runs.

In practice, hardly any application can be built entirely from pure functions, I/O has to happen somewhere. The pragmatic strategy is to consistently push side effects to the edge of the application and keep the core of the business logic as pure as possible, a pattern often called "functional core, imperative shell." A method that simultaneously performs a calculation and writes the result to a file cannot be tested in isolation without touching the filesystem. Splitting calculation and write operation into two methods turns the calculation into a pure function that is testable without any infrastructure at all.

The effect on the testability of a codebase that follows this principle consistently is substantial: the vast majority of the logic can be checked with simple input and output values, while only a thin layer at the edge actually needs I/O tests. The following example first shows a method that mixes calculation and side effect, then the split, testable variant.


<?php

declare(strict_types=1);

namespace App\Invoice;

// Before: calculation and side effect mixed in a single method,
// untestable without touching the filesystem.
final class InvoiceReportBefore
{
    public function writeTotal(array $items): void
    {
        $total = 0.0;
        foreach ($items as $item) {
            $total += $item->price * $item->quantity;
        }

        file_put_contents('/var/reports/total.txt', (string) $total);
    }
}

// After: pure calculation, fully testable with plain input/output values.
final class InvoiceCalculator
{
    /**
     * @param array<int, InvoiceItem> $items
     */
    public function calculateTotal(array $items): float
    {
        $total = 0.0;
        foreach ($items as $item) {
            $total += $item->price * $item->quantity;
        }

        return $total;
    }
}

// Side effect isolated at the edge of the application.
final readonly class InvoiceReportWriter
{
    public function __construct(
        private FilesystemInterface $filesystem,
    ) {
    }

    public function write(float $total, string $path): void
    {
        $this->filesystem->put($path, (string) $total);
    }
}

5. Static Methods and Singletons as Testability Killers

Static method calls are one of the most reliable testability killers in PHP code. The reason lies in the compile-time binding: a call like Logger::write($message) is hard-wired to the Logger class, there is no way to substitute a different implementation in a test without changing the caller itself. While an injected dependency can be swapped out via an interface, a static call remains immutable to the caller, exactly the opposite of what a seam is supposed to provide.

Singletons make the problem even worse, because they carry global, mutable state across the entire runtime of the application. A test that manipulates a singleton instance can alter the state for all subsequent tests in the same process, regardless of test order or test class. This leads to the infamous tests that only pass in isolation but not in the full suite, and to a debugging effort that is completely out of proportion to the original benefit of the singleton. The hidden access to a global instance is also invisible to anyone reading a method, unlike a dependency that is visibly declared in the constructor.

The practical consequence for testable design is this: stateless static utility methods, pure calculation functions for instance, are unproblematic because they behave like pure functions. Static methods that operate on databases, the filesystem, time, or application-wide state must be converted into instance methods of an injectable class. The conversion is mechanical: a static function becomes an ordinary method, global access becomes constructor injection, and the calling class receives the dependency through its own constructor instead of through a global access point.

6. Interfaces as a Contract: Program to an Interface, Not an Implementation

"Program to an interface, not an implementation" is more than a slogan from the Gang of Four book, it is the direct prerequisite for test doubles to be usable at all. A class that programs against a concrete implementation instead of an interface cannot replace that implementation with a stub or a fake in a test without bending the language itself. Only the interface creates the contract that both the real implementation and the test double can follow, without the caller noticing the difference.

The Liskov substitution principle is not an academic footnote here, but a hard requirement for every test double: a mock or stub must behave, everywhere the real implementation is expected, exactly so that the caller notices no difference, except in the concrete return value. If an implementation violates this principle, for instance by throwing additional exceptions not documented in the interface, every test double based on that interface becomes an incomplete abstraction of reality, and tests pass even though production behaves differently.

Interface segregation further strengthens testability: a small, focused interface with two or three methods can be implemented as a test double with minimal effort. A large interface with fifteen methods, of which a class actually needs only two, forces every test double to also implement the remaining thirteen methods, usually with placeholders that obscure the intent of the test. Small, role-specific interfaces, oriented around what a consumer actually needs rather than what a class can do overall, are therefore a direct lever for simpler, more meaningful tests.

7. Encapsulating Time, Randomness, and I/O

Time, randomness, and input/output are the three most common sources of non-determinism in PHP applications, and non-determinism is the natural enemy of a reproducible test. A test based on new DateTimeImmutable() inside the class under test yields a different result on a Monday than on a Tuesday. The solution is always the same: the source of non-determinism is hidden behind an interface and injected as a dependency instead of being called directly in the code.

The clock pattern replaces every scattered call to new DateTimeImmutable() with an injected ClockInterface that has a single now() method. In tests, a FrozenClock or a FixedClock always returns the same, fixed point in time, which makes assertions about time-dependent behavior, expiry dates or deadlines for instance, possible without race conditions and without sleep calls in the test. The same principle applies to random numbers: a randomizer interface, supported since PHP 8.2 by the built-in Randomizer class, replaces direct calls to rand() or random_int() and allows a predictable random generator to be substituted in tests.

The same logic applies to filesystem access: a filesystem abstraction with methods like read(), write(), and exists() replaces direct calls to file_get_contents(), file_put_contents(), and file_exists(). The concrete implementation of this abstraction may be replaced in the test either by an in-memory fake or, where it makes sense, by a temporary directory, without the business logic itself ever knowing whether it is working against a real filesystem or a fake. These three encapsulations, time, randomness, and I/O, remove the three most common causes of flaky, non-reproducible tests from a codebase.

8. Making Legacy Code Testable Step by Step

Existing code written without regard for testability can rarely be converted in a single step. Michael Feathers, in his standard work on legacy code, describes the pragmatic entry point via characterization tests: tests that document not the desired but the actual behavior of the existing code, before any refactoring even begins. These tests form a safety net that prevents the application's observable behavior from changing unintentionally during the gradual move toward testable design.

Two techniques have proven themselves for gradual conversion. Extract and override extracts a problematic call, new DateTimeImmutable() or a direct database access for instance, into its own protected method. In a test class that inherits from the original class, exactly this method is overridden to return a controlled value, without touching the rest of the class. The sprout method technique takes the opposite path: instead of changing existing code, new functionality is developed from the start as a testable, separate method or class and only hooked into the legacy code at the end.

Both techniques share the advantage of not requiring a big-bang rewrite, a decisive benefit in grown codebases where a complete rebuild is not economically viable. Every retrofitted seam reduces the risk of the next change at that same spot, because a test now actually exists that guards the behavior there. Testability thus stops being a property a codebase either has or lacks, and becomes instead a direction every single change can move toward.


<?php

declare(strict_types=1);

namespace App\Legacy;

/**
 * Legacy class with a hard-coded time dependency.
 * Extract & Override: the problematic call is moved into its own
 * protected method so a test subclass can override it.
 */
class SubscriptionRenewal
{
    public function isDue(Subscription $subscription): bool
    {
        $now = $this->getCurrentTime();

        return $now >= $subscription->getRenewalDate();
    }

    // Seam: protected so a test subclass can override the return value
    protected function getCurrentTime(): \DateTimeImmutable
    {
        return new \DateTimeImmutable();
    }
}

/**
 * Test-only subclass overriding the seam with a fixed point in time.
 * No mocking framework required, just plain inheritance.
 */
final class TestableSubscriptionRenewal extends SubscriptionRenewal
{
    public function __construct(
        private readonly \DateTimeImmutable $fixedNow,
    ) {
    }

    protected function getCurrentTime(): \DateTimeImmutable
    {
        return $this->fixedNow;
    }
}

9. Testable Design Compared: Antipatterns vs. Recommended Patterns

The following five comparisons summarize the most common antipatterns from the preceding sections and contrast each with the recommended, testable pattern. None of the columns on the right require a specific test framework, they describe purely design decisions in the production code itself.

Task Antipattern Recommended Pattern Benefit
Time-dependent logic new DateTimeImmutable() inside the method body Inject ClockInterface Fixed point in time possible in the test
Creating a dependency new Service() inside a method Inject interface via constructor Replaceable with a test double
Global access Logger::write() called statically LoggerInterface injected No global state, no compile-time binding
Application-wide state Singleton::getInstance() Injected service with a defined lifecycle No shared state between tests
Logic and I/O mixed together Calculation + file_put_contents() in one method Pure calculation function + separate writer Calculation testable without infrastructure

In every row, the decisive difference is not the line count or the elegance of the syntax, but the question of whether the behavior of that row can be swapped out in a test without changing the production code itself. That is exactly the definition of testable design: control points where a test can intervene without knowing the class that will actually use it later.

10. Summary

Testability is not a property a test runner adds to a codebase after the fact. It is built at the drawing board: through dependency injection instead of hard-coded dependencies, through seams at the points where global functions and external resources are touched, through pure functions at the core of the business logic, and by avoiding static calls and singletons. PHP 8.4 makes consistent implementation so compact with constructor property promotion that there is no longer any syntactic excuse for hard-coding dependencies.

For existing code, the rule is: testable design can be retrofitted step by step, using characterization tests as a safety net and techniques like extract and override, without needing a complete rewrite. The decisive shift in perspective is to stop treating testability as a task of the test phase and instead treat it as an ongoing criterion in every design decision, regardless of which framework ends up running the tests.

Testable Design in PHP, the Essentials

Dependency Injection

Inject dependencies through the constructor instead of creating them with new inside a method. The basic prerequisite for any test double.

Encapsulate Seams

Hide time(), rand(), curl_exec(), and file_get_contents() behind interfaces. The humble object stays thin, the logic behind it is testable.

Avoid Statics

Static calls and singletons are bound at compile time and cannot be substituted in tests. Injected interfaces can.

Pure Functions

Separate calculation from side effects. Pure functions need no setup, no teardown, no mocks.

11. FAQ: Testability as a Design Principle

1What does testability mean in the context of PHP architecture?
How easily a class can be examined in isolation from its dependencies, regardless of the test framework in use.
2Is testability the same as test coverage?
No. Coverage measures how much code tests execute. Testability measures how easily code can be tested in the first place.
3Why is new inside a method a problem?
It ties the class firmly to a concrete implementation that cannot be replaced in a test. Injection through the constructor solves this.
4What is a seam?
A place in the code where behavior can be swapped out without editing the code itself. In PHP, usually an interface.
5Why are static methods hard to test?
They are hard-wired at compile time and cannot be replaced by a different implementation in a test.
6Test double vs. real implementation?
A test double fulfills the same contract as the real implementation but returns controlled, predictable results.
7Do I need to create an interface for every class?
No, only where a dependency has side effects or needs to be replaced in tests. Pure classes rarely need one.
8Making legacy code testable without a rewrite?
Through characterization tests as a safety net and techniques like extract and override, step by step instead of in a rewrite.
9What is a pure function?
It always returns the same output for the same inputs with no side effects, so it needs no setup or mocks in a test.
10Does testable design cost more time?
A bit more short term due to interfaces and injection, considerably less medium term thanks to simpler tests and safer refactorings.

Mironsoft

PHP architecture consulting, legacy code refactoring, and testability reviews

PHP code that actually can be tested?

We analyze existing PHP classes for testability, uncover hard-coded dependencies and hidden side effects, and refactor them step by step toward testable design, regardless of which test framework you end up working with.

Testability Review

We identify new calls, statics, and singletons that shield your classes from testing.

Architecture Refactoring

Retrofit dependency injection, seams, and pure functions step by step, without a big-bang rewrite.

Legacy Code Coaching

Characterization tests and extract and override, so existing code safely moves toward testability.