PHPUnit Extension API: Writing Custom Extensions Since PHPUnit 10
AI generated
@test
assert
PHPUnit · Extension API · PHP
PHPUnit Extension API
Writing custom extensions since PHPUnit 10

PHPUnit 10 replaced the long-grown TestListener with a cleanly typed Extension API. Anyone who wants custom logging, Slack notifications on failure, or project-specific rules inside their test suite will find the migration path here, with runnable examples.

15 min read Extension API TestListener replacement PHPUnit 10+

1. Why the TestListener was replaced

The old TestListener from PHPUnit 9 and earlier was a single interface with more than a dozen methods such as startTest(), endTest(), or addError(). Anyone who only wanted to react to failed tests still had to implement every other method as an empty stub. The interface was also tightly coupled to internal PHPUnit classes, so every major version brought its own breaking changes and extensions constantly needed maintenance just to keep working.

PHPUnit 10 brought a complete fresh start: the Extension API. Instead of one monolithic interface, there are now fine-grained events you subscribe to individually, plus a dedicated Extension interface for registration. The benefit is not just better type safety, it also means extensions only react to the events they actually care about, which keeps them far more resilient against internal PHPUnit changes.

2. How the Extension API is structured

At the center sits the interface PHPUnit\Runner\Extension\Extension with exactly one method: bootstrap(). This method receives the configuration, a facade for registration access, and a parameter object. Inside bootstrap() you register for concrete events such as Test\Finished, Test\Failed, or TestSuite\Started by passing a subscriber that implements an event-specific interface.

This separation between extension bootstrap and event subscriber ensures each extension only subscribes to events it genuinely needs. A Slack notifier, for instance, only cares about failed and finished test runs, not every single passing test. That not only cuts boilerplate, it also makes runtime performance more predictable, since PHPUnit only dispatches the events that are actually subscribed to.


<?php

declare(strict_types=1);

namespace App\Testing\Extension;

use PHPUnit\Runner\Extension\Extension;
use PHPUnit\Runner\Extension\Facade;
use PHPUnit\Runner\Extension\ParameterCollection;
use PHPUnit\TextUI\Configuration\Configuration;

/**
 * Skeleton of a custom PHPUnit extension.
 */
final class SlackNotifierExtension implements Extension
{
    public function bootstrap(
        Configuration $configuration,
        Facade $facade,
        ParameterCollection $parameters
    ): void {
        $webhookUrl = $parameters->has('webhookUrl')
            ? $parameters->get('webhookUrl')
            : getenv('SLACK_WEBHOOK_URL') ?: '';

        $facade->registerSubscribers(
            new TestFailedSubscriber($webhookUrl),
            new TestSuiteFinishedSubscriber($webhookUrl),
        );
    }
}

3. TestListener and Extension API side by side

Anyone migrating an existing extension quickly finds that not every old listener method maps one to one onto an event. startTestSuite() and endTestSuite() correspond fairly directly to TestSuite\Started and TestSuite\Finished, while the fine-grained error kinds like addError(), addFailure(), and addWarning() now each get their own events such as Test\Errored, Test\Failed, and Test\WarningTriggered.

The biggest practical difference lies in registration: a TestListener was wired up as a fully instantiated PHP object with constructor arguments in phpunit.xml, which quickly became unwieldy for anything with real dependencies. An extension instead gets its configuration through simple parameters from the XML file and builds its own dependencies inside bootstrap(), which makes the configuration considerably more readable and makes the extension itself easier to test.

4. Implementing an event subscriber

Every event subscriber implements an interface tied to exactly one event, for example PHPUnit\Event\Test\FailedSubscriber with the method notify(Failed $event). The event object provides everything needed for a meaningful notification: the full test name, the failure message, and a timestamp. Because the event system is typed, there is no more guesswork about which data is available in which method.

For the Slack notifier, it is enough to accept the webhook URL in the constructor and issue an HTTP request inside notify(). It is important to keep the subscriber itself as thin as possible and move the actual sending logic into a separate, independently testable class, so that class can be verified with an ordinary PHPUnit test against a mocked HTTP client instead of relying on the real PHPUnit run.


<?php

declare(strict_types=1);

namespace App\Testing\Extension;

use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\FailedSubscriber;

/**
 * Reacts to failed tests and triggers a Slack message.
 */
final class TestFailedSubscriber implements FailedSubscriber
{
    public function __construct(private readonly string $webhookUrl)
    {
    }

    public function notify(Failed $event): void
    {
        if ($this->webhookUrl === '') {
            return;
        }

        $message = sprintf(
            'Test failed: %s%s%s',
            $event->test()->name(),
            PHP_EOL,
            $event->throwable()->message(),
        );

        $this->send($message);
    }

    private function send(string $message): void
    {
        $context = stream_context_create([
            'http' => [
                'method' => 'POST',
                'header' => 'Content-Type: application/json',
                'content' => json_encode(['text' => $message], JSON_THROW_ON_ERROR),
            ],
        ]);

        @file_get_contents($this->webhookUrl, false, $context);
    }
}

5. Example: structured logging per test run

A common use case besides notifications is custom structured logging, for instance writing per-class runtimes to a JSON file that later feeds a monitoring dashboard. For that you subscribe to Test\Started to record the start time, and Test\Finished to compute the difference and persist the result.

Because extensions are free to build any dependencies inside bootstrap(), you can inject a normal PSR-3 compatible logger that writes the collected timings to a file or an external service at the end of the run. That is considerably more flexible than the older pattern of passing timing information between listener methods via global variables or static class attributes.


<?php

declare(strict_types=1);

namespace App\Testing\Extension;

use PHPUnit\Event\Test\Finished;
use PHPUnit\Event\Test\FinishedSubscriber;
use PHPUnit\Event\Test\Started;
use PHPUnit\Event\Test\StartedSubscriber;

/**
 * Measures the runtime of every test and writes it out in a structured way.
 */
final class TimingStartedSubscriber implements StartedSubscriber
{
    public function __construct(private readonly TimingCollector $collector)
    {
    }

    public function notify(Started $event): void
    {
        $this->collector->start($event->test()->id());
    }
}

final class TimingFinishedSubscriber implements FinishedSubscriber
{
    public function __construct(private readonly TimingCollector $collector)
    {
    }

    public function notify(Finished $event): void
    {
        $this->collector->finish($event->test()->id());
    }
}

6. Registering the extension in phpunit.xml

For PHPUnit to load an extension at all, it must be entered in the <extensions> block of phpunit.xml. Unlike the old TestListener, you no longer pass constructor arguments directly, instead you pass simple key-value parameters that are then read from the ParameterCollection object inside bootstrap().

This separation of configuration and object creation has a practical side effect: sensitive values such as webhook URLs or API tokens no longer need to live in plain text inside the version-controlled phpunit.xml, they can instead be injected via environment variables while the XML file only references the name of the variable to read.


<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php">
    <testsuites>
        <testsuite name="unit">
            <directory>tests/Unit</directory>
        </testsuite>
    </testsuites>

    <extensions>
        <bootstrap class="App\Testing\Extension\SlackNotifierExtension">
            <parameter name="webhookUrl" value="${SLACK_WEBHOOK_URL}"/>
        </bootstrap>
        <bootstrap class="App\Testing\Extension\TimingLoggerExtension">
            <parameter name="logFile" value="var/log/phpunit-timings.json"/>
        </bootstrap>
    </extensions>
</phpunit>

7. Keeping extensions themselves testable

An extension that only consumes internal PHPUnit events is hard to test inside a running PHPUnit process itself without creating recursive dependencies. The proven approach is therefore to move the actual business logic, such as formatting a Slack message or assembling a JSON log entry, into a plain class that has no dependency on PHPUnit classes at all.

The event subscriber itself then stays deliberately thin: it only forwards event data to that business class. That lets you verify the formatting and sending logic with an ordinary unit test against a mocked HTTP client, without having to simulate a full PHPUnit run inside a test run, which would be technically problematic anyway. This principle of separating business logic from PHPUnit-specific infrastructure also pays off once an extension needs to be reused in another project later, since the business class can then be adopted without modification.

8. Common pitfalls during the migration

A frequent mistake is overlooking the namespace difference between the old PHPUnit 9 classes and the new event interfaces. Anyone who accidentally still implements PHPUnit\Framework\TestListener gets neither an error nor a warning on PHPUnit 10 and later, the extension is simply never called because the old interface was removed entirely and PHPUnit no longer looks for it at all.

A second, more subtle mistake is subscribing to too many events out of sheer caution. Anyone who, for example, subscribes to both Test\Finished and Test\Passed even though only failed tests matter creates unnecessary overhead on large suites with thousands of tests. It pays to check carefully before implementation which of the more than one hundred available event types actually delivers the required information.

9. Conclusion for production use

The Extension API is not merely a replacement for the TestListener, it is a considerably more robust foundation for project-specific test infrastructure. For Magento and larger PHP projects, the effort pays off especially when CI pipelines depend on fast feedback and a failure on the main branch needs to show up immediately in the team chat instead of only being noticed on the next glance at the CI dashboard.

Anyone still working with an old TestListener from PHPUnit 9 should not postpone the migration: from PHPUnit 10 onward there is no compatibility mode at all, and the longer you wait, the more legacy listener code accumulates that then has to be migrated in one go during the actual version upgrade.

Aspect TestListener (PHPUnit 9) Extension API (PHPUnit 10+) Practical relevance
Registration Fully instantiated PHP object in phpunit.xml Class name plus simple parameters Less coupling to constructor signatures
Granularity One interface with every method One interface per event type Subscribe only to relevant events
Type safety Generic parameters such as TestCase Typed event objects Better IDE support and fewer runtime errors
Configuration data Directly in the XML constructor Via ParameterCollection Inject sensitive values via environment variables
Compatibility Removed from PHPUnit 10 Current standard Migration is not optional

Mironsoft

Test automation, Magento quality assurance, and CI integration

Tests that catch real bugs instead of just turning green?

We review existing PHPUnit suites for implementation-detail tests, flaky tests, and missing coverage at critical points, then build a test strategy that provides real confidence with every Magento update.

Test Audit

Reviewing existing suites for mocking antipatterns and blind spots.

Test Strategy

Meaningfully combining unit, integration, and MFTF tests for Magento projects.

CI Integration

Setting up fast, reliable test runs in GitLab CI or GitHub Actions.

10. Summary

PHPUnit Extension API: The Key Facts at a Glance

Replacement

The Extension API fully replaces the TestListener from PHPUnit 10 onward, there is no compatibility mode.

Architecture

An extension bootstrap registers typed event subscribers instead of one monolithic interface.

Configuration

Parameters come from phpunit.xml, sensitive values can be injected via environment variables.

Testability

Business logic belongs in separate classes, the event subscriber itself stays deliberately thin.

11. FAQ: PHPUnit Extension API: The Key Facts at a Glance

1From which PHPUnit version is the TestListener fully removed?
The TestListener was completely removed in PHPUnit 10. There is no compatibility mode, projects must switch to the Extension API to upgrade to PHPUnit 10 or later.
2What is the most important structural difference from the old solution?
Instead of one interface with many mandatory methods, you now subscribe individually to typed events through separate subscriber classes, which reduces boilerplate and improves IDE support.
3How do you pass configuration values to a custom extension?
Through the tag inside the block of phpunit.xml. Inside bootstrap() you read the values from the ParameterCollection object instead of passing them to an XML constructor.
4Can an extension subscribe to multiple events at once?
Yes, via facade->registerSubscribers() you can register any number of subscriber instances at once, each for a different event, all within the same bootstrap() method.
5How do you sensibly test a custom extension?
The business logic, such as message formatting or log serialization, belongs in a standalone class with no PHPUnit dependencies. That class can then be verified with an ordinary unit test.
6What events exist for failed tests?
Among others, Test\Failed for assertion failures, Test\Errored for unexpected exceptions, and Test\WarningTriggered for warnings. Every failure kind has its own typed event.
7Is a custom extension worth it for small projects?
For very small projects, a simple CI script evaluating PHPUnit's exit code is often enough. Custom extensions pay off mainly when project-specific logic needs to hook directly into test events.
8Can you register multiple extensions at once?
Yes, the block of phpunit.xml can hold any number of bootstrap entries with different extension classes, each with its own parameters.
9What happens if an old TestListener class is still listed in phpunit.xml?
From PHPUnit 10 onward the configuration is typically rejected as an error, or the class is simply ignored, since the TestListener interface no longer exists. Reading the PHPUnit migration notes before upgrading is essential.
10Are there ready-made extensions to use instead of writing your own?
Yes, the Composer ecosystem already has extensions for coverage reporting, test order randomization, and CI integrations. For very specific needs like internal chat notifications, a custom extension often remains the most pragmatic solution.