11. Error Handling

PHPUnit’s test runner registers an error handler and processes E_DEPRECATED, E_USER_DEPRECATED, E_NOTICE, E_USER_NOTICE, E_STRICT, E_WARNING, and E_USER_WARNING errors. We will use the term “issues” to refer to E_DEPRECATED, E_USER_DEPRECATED, E_NOTICE, E_USER_NOTICE, E_STRICT, E_WARNING, and E_USER_WARNING errors for the remainder of this chapter.

E_STRICT was deprecated in PHP 8.4. On PHP versions where E_STRICT errors still exist, PHPUnit processes them as if they were E_NOTICE errors.

The error handler also processes E_USER_ERROR errors. These are not issues in the sense used in this chapter, they are handled differently (see Errors (E_USER_ERROR)).

The error handler is active while a test is running and only processes issues triggered by test code or code that is called from test code. It ignores issues triggered by PHPUnit’s own code as well as code from PHPUnit’s dependencies. Issues that are triggered while the test suite is loaded, before the first test is run, are processed separately (see Issues triggered outside of tests).

Other error handlers

When PHPUnit’s test runner becomes aware (after it called set_error_handler() to register its error handler) that another error handler was registered then it keeps its error handler active and calls the previously registered error handler before it processes an issue. Consequently, the features described in this chapter remain available when you use your own error handler. The return value of the previously registered error handler does not prevent PHPUnit’s test runner from processing an issue. While the previously registered error handler runs, error_reporting() returns the value it would return without PHPUnit’s test runner, so error handlers that check error_reporting() dynamically (also to respect the @ operator) behave as they do in production.

When the previously registered error handler throws an exception, for instance to turn errors into exceptions, then this exception replaces the error: PHPUnit’s test runner does not process an issue and the exception propagates into the test, where it can be expected using expectException(), for example. Be aware that this also applies to deprecations: when the previously registered error handler throws an exception for E_DEPRECATED or E_USER_DEPRECATED errors then a test that triggers such an error will be reported as errored instead of as having triggered a deprecation.

The #[WithoutErrorHandler] attribute can be used to disable PHPUnit’s error handler for a test method so that only the previously registered error handler is active while the test method is run.

Your own error handler should follow best practices

Your own error handler should ignore errors emitted by code it is not responsible for, for instance PHPUnit’s code.

The error handler emits events that are, for instance, subscribed to and used by the default progress and result printers as well as loggers.

Here is the code that we will use for the examples in the remainder of this chapter:

.
├── phpunit.xml
├── src
│   └── FirstPartyClass.php
├── tests
│   └── FirstPartyClassTest.php
└── vendor
    ├── autoload.php
    └── ThirdPartyClass.php

4 directories, 5 files
Example 11.1 tests/FirstPartyClassTest.php
<?php declare(strict_types=1);
namespace example;

use PHPUnit\Framework\TestCase;
use vendor\ThirdPartyClass;

final class FirstPartyClassTest extends TestCase
{
    public function testOne(): void
    {
        $this->assertTrue((new FirstPartyClass)->method());
    }

    public function testTwo(): void
    {
        $this->assertTrue((new ThirdPartyClass)->anotherMethod());
    }
}
Example 11.2 src/FirstPartyClass.php
<?php declare(strict_types=1);
namespace example;

use function trigger_error;
use vendor\ThirdPartyClass;

final class FirstPartyClass
{
    public function method(): true
    {
        (new ThirdPartyClass)->method();

        trigger_error('deprecation in first-party code', E_USER_DEPRECATED);

        return true;
    }
}
Example 11.3 vendor/ThirdPartyClass.php
<?php declare(strict_types=1);
namespace vendor;

use example\FirstPartyClass;

final class ThirdPartyClass
{
    public function method(): void
    {
        trigger_error('deprecation in third-party code', E_USER_DEPRECATED);
    }

    public function anotherMethod(): true
    {
        return (new FirstPartyClass)->method();
    }
}
Example 11.4 phpunit.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.1/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         cacheDirectory=".phpunit.cache"
>
    <testsuites>
        <testsuite name="default">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
</phpunit>

PHPUnit’s test runner prints D, N, and W, respectively, for tests that execute code which triggers an issue (D for deprecations, N for notices, and W for warnings).

Shown below is the default output PHPUnit’s test runner prints for the example shown above:

$ ./tools/phpunit
PHPUnit 13.4.0 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.10
Configuration: /path/to/example/phpunit.xml

DD                                                                  1 / 1 (100%)

Time: 00:00.002, Memory: 8.00 MB

OK, but there were issues!
Tests: 2, Assertions: 2, Deprecations: 2.

Detailed information, for instance which issue was triggered where, is only printed when --display-deprecations, --display-phpunit-deprecations, --display-phpunit-notices, --display-errors, --display-notices, --display-warnings, or --display-all-issues is used:

$ ./tools/phpunit --display-deprecations
PHPUnit 13.4.0 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.10
Configuration: /path/to/example/phpunit.xml

DD                                                                  1 / 1 (100%)

Time: 00:00.002, Memory: 8.00 MB

2 tests triggered 2 deprecations:

1) /path/to/vendor/ThirdPartyClass.php:10
deprecation in third-party code

Triggered by:

* exampleFirstPartyClassTest::testOne
  /path/to/tests/FirstPartyClassTest.php:17

* exampleFirstPartyClassTest::testTwo
  /path/to/tests/FirstPartyClassTest.php:22

2) /path/to/src/FirstPartyClass.php:13
deprecation in first-party code

Triggered by:

* exampleFirstPartyClassTest::testOne
  /path/to/tests/FirstPartyClassTest.php:17

* exampleFirstPartyClassTest::testTwo
  /path/to/tests/FirstPartyClassTest.php:22

OK, but there were issues!
Tests: 2, Assertions: 2, Deprecations: 2.

Each of these CLI options has a corresponding attribute on the <phpunit> element of the XML configuration file, for instance displayDetailsOnTestsThatTriggerDeprecations (see The displayDetailsOnTestsThatTriggerDeprecations Attribute).

By default, issues do not affect the shell exit code: it indicates success even when the tests triggered issues. The failOnDeprecation, failOnNotice, and failOnWarning attributes as well as the failOnAllIssues attribute (and the corresponding --fail-on-* CLI options) can be used to let the test runner exit with a shell exit code that indicates failure when issues were triggered. Enabling one of these settings implicitly enables the display of the corresponding issue details.

Limiting issues to “your code”

A common problem is that dependencies in vendor trigger deprecation warnings, notices, or warnings that clutter your test output. The reporting of issues can be limited to “your code” so that you only see issues that originate from code you are responsible for.

First, you need to configure what you consider “your code” using the <source> element in your XML configuration file (see The <source> Element). Then you can use the following attributes on the <source> element to filter issues:

  • ignoreIndirectDeprecations="true" ignores E_DEPRECATED and E_USER_DEPRECATED triggered by third-party code (e.g. code in vendor)

  • restrictNotices="true" ignores E_NOTICE, E_USER_NOTICE, and E_STRICT triggered by third-party code

  • restrictWarnings="true" ignores E_WARNING and E_USER_WARNING triggered by third-party code

Here is a configuration that only reports issues from your own code:

Example 11.5 phpunit.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.1/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         cacheDirectory=".phpunit.cache"
>
    <testsuites>
        <testsuite name="default">
            <directory>tests</directory>
        </testsuite>
    </testsuites>

    <source ignoreIndirectDeprecations="true"
            restrictNotices="true"
            restrictWarnings="true">
        <include>
            <directory>src</directory>
        </include>
    </source>
</phpunit>

Here is what the output of PHPUnit’s test runner will look like after we configured (see above) it to restrict the reporting of issues to our own code:

$ ./tools/phpunit --display-deprecations
PHPUnit 13.4.0 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.10
Configuration: /path/to/example/phpunit.xml

DD                                                                  1 / 1 (100%)

Time: 00:00.002, Memory: 8.00 MB

2 tests triggered 2 deprecations:

1) /path/to/vendor/ThirdPartyClass.php:10
deprecation in third-party code

Triggered by:

* exampleFirstPartyClassTest::testOne
  /path/to/tests/FirstPartyClassTest.php:17

* exampleFirstPartyClassTest::testTwo
  /path/to/tests/FirstPartyClassTest.php:22

2) /path/to/src/FirstPartyClass.php:13
deprecation in first-party code

Triggered by:

* exampleFirstPartyClassTest::testOne
  /path/to/tests/FirstPartyClassTest.php:17

OK, but there were issues!
Tests: 2, Assertions: 2, Deprecations: 2.

As you can see in the output shown above, deprecations triggered by third-party code located in the vendor directory are not reported anymore.

The following attributes can be used on the <source> element to configure how PHPUnit uses the information what your code is:

PHPUnit only identifies how an issue was triggered when the <source> element is configured. This identification can also be disabled explicitly by setting identifyIssueTrigger="false" on the <source> element (see The <identifyIssueTrigger> Attribute). When trigger identification is disabled, all issues are treated as if it was unknown whether they were triggered by first-party code or third-party code: the ignoreSelfDeprecations, ignoreDirectDeprecations, and ignoreIndirectDeprecations settings as well as the trigger-specific failOn*Deprecation settings discussed below have no effect. The restrictNotices and restrictWarnings settings are not affected by this: they only consider the location where the issue was triggered.

Failing on deprecations based on how they are triggered

The failOnDeprecation setting (see The failOnDeprecation Attribute) and the --fail-on-deprecation CLI option are all-or-nothing: when they are used, every deprecation that is reported causes PHPUnit’s test runner to exit with a shell exit code that indicates failure.

This is not always what you want. Deprecations triggered by first-party code in first-party code (self) or by first-party code in third-party code (direct) are deprecations that you can act on by changing your own code. Deprecations triggered by third-party code in third-party code (indirect) are usually outside of your control: you may want to see them without letting them fail the test run.

When PHPUnit can identify how a deprecation was triggered, which requires the <source> element (see The <source> Element) to be configured, the shell exit code can be controlled separately for each trigger type:

  • the failOnSelfDeprecation attribute and the --fail-on-self-deprecation / --do-not-fail-on-self-deprecation CLI options control failing on deprecations triggered by first-party code in first-party code

  • the failOnDirectDeprecation attribute and the --fail-on-direct-deprecation / --do-not-fail-on-direct-deprecation CLI options control failing on deprecations triggered by first-party code in third-party code

  • the failOnIndirectDeprecation attribute and the --fail-on-indirect-deprecation / --do-not-fail-on-indirect-deprecation CLI options control failing on deprecations triggered by third-party code

These settings are overrides that are layered on top of failOnDeprecation:

  • By default, each trigger type follows the failOnDeprecation setting.

  • Configuring failOnSelfDeprecation="true", failOnDirectDeprecation="true", or failOnIndirectDeprecation="true" (or using the corresponding --fail-on-* CLI option) enables failing on deprecations of that trigger type even when failOnDeprecation is not enabled.

  • Using --do-not-fail-on-self-deprecation, --do-not-fail-on-direct-deprecation, or --do-not-fail-on-indirect-deprecation exempts deprecations of that trigger type from failing the test run even when failOnDeprecation or failOnAllIssues is enabled.

  • Deprecations that are not classified as self, direct, or indirect (for instance deprecations triggered by test code, or deprecations for which no trigger could be identified) always follow the failOnDeprecation setting.

Configuring failOnSelfDeprecation="false", failOnDirectDeprecation="false", or failOnIndirectDeprecation="false" in the XML configuration file cannot exempt a trigger type when failOnDeprecation is enabled: only the --do-not-fail-on-* CLI options can do that. So that this conflict does not go unnoticed, PHPUnit emits a test runner warning for such a setting that has no effect, for example failOnSelfDeprecation="false" has no effect because failOnDeprecation is enabled. Use the --do-not-fail-on-self-deprecation CLI option instead.

Here is a configuration for a common workflow: deprecations that you can act on (self and direct) fail the test run while indirect deprecations are only reported:

Example 11.6 phpunit.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/13.3/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         cacheDirectory=".phpunit.cache"
         failOnSelfDeprecation="true"
         failOnDirectDeprecation="true"
>
    <testsuites>
        <testsuite name="default">
            <directory>tests</directory>
        </testsuite>
    </testsuites>

    <source>
        <include>
            <directory>src</directory>
        </include>
    </source>
</phpunit>

The same effect can be achieved on the command line by combining --fail-on-deprecation with --do-not-fail-on-indirect-deprecation.

A deprecation that is exempted from failing the test run is still counted and still displayed: the test runner prints “OK, but there were issues!”, but the shell exit code indicates success. Like failOnDeprecation, the trigger-specific settings implicitly enable the display of deprecation details.

Together with the ignoreSelfDeprecations, ignoreDirectDeprecations, and ignoreIndirectDeprecations attributes on the <source> element that were discussed earlier in this chapter, each trigger type supports three states: a deprecation can be reported and fail the test run, it can be reported without failing the test run, or it can be ignored entirely.

Ignoring issue suppression

By default, the error handler registered by PHPUnit’s test runner respects the suppression operator (@). This means that issues triggered using @trigger_error(), for example, will not be reported by the default progress and result printers.

The suppression of issues using the suppression operator (@) can be ignored by configuration settings in PHPUnit’s XML configuration file:

Ignoring previously reported issues

PHPUnit’s test runner supports declaring the currently reported list of issues. Issues that are on this so-called baseline are no longer reported. This allows you to focus on new issues that are triggered by new or changed code.

When you run your test suite using the --generate-baseline CLI option then PHPUnit’s test runner will write a list of all issues that are triggered to an XML file:

$ phpunit --generate-baseline baseline.xml
PHPUnit 13.4.0 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.2.10
Configuration: /path/to/example/phpunit.xml

D                                                                   1 / 1 (100%)

Time: 00:00.008, Memory: 4.00 MB

OK, but there were issues!
Tests: 1, Assertions: 1, Deprecations: 1.

Baseline written to /path/to/example/baseline.xml.

When you run your test suite using the --use-baseline CLI option (or if you have configured a baseline in your XML configuration file for PHPUnit using the The <baseline> Attribute setting) then PHPUnit’s test runner will use this list of already known issues to ignore them for the current run:

$ phpunit --use-baseline baseline.xml
PHPUnit 13.4.0 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.2.10
Configuration: /path/to/example/phpunit.xml

.                                                                   1 / 1 (100%)

Time: 00:00.007, Memory: 4.00 MB

OK (1 test, 1 assertion)

2 issues were ignored by baseline.

The --ignore-baseline CLI option can be used to ignore a baseline that is configured in the XML configuration file for a single test run so that all issues are reported again.

Deprecations, notices, and warnings that are triggered by tests are put on the baseline when it is generated. PHPUnit’s own deprecations, notices, and warnings as well as issues triggered outside of tests (see Issues triggered outside of tests) are not put on the baseline. Issues that are suppressed using the suppression operator (@) are only put on the baseline when the corresponding ignoreSuppressionOf* setting (see above) is configured.

Expecting Deprecations (E_USER_DEPRECATED)

The expectUserDeprecationMessage() method can be used to expect that an E_USER_DEPRECATED issue with a specified message is triggered.

Example 11.7 Usage of expectUserDeprecationMessage()
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;

final class DeprecationExpectationTest extends TestCase
{
    public function testFailure(): void
    {
        $this->expectUserDeprecationMessage('the-deprecation-message');
    }
}

Running the test shown above yields the output shown below:

./tools/phpunit tests/DeprecationExpectationTest.php
PHPUnit 13.4.0 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.11

F                                                                   1 / 1 (100%)

Time: 00:00, Memory: 32.33 MB

There was 1 failure:

1) DeprecationExpectationTest::testFailure
Expected deprecation with message "the-deprecation-message" was not triggered

FAILURES!
Tests: 1, Assertions: 1, Failures: 1.

Alternatively, the $this->expectUserDeprecationMessageMatches() can be used to expect that an E_USER_DEPRECATED issue is triggered where the deprecation message matches a specified regular expression.

This can be used together with the #[IgnoreDeprecations] attribute to not let the test fail.

The #[IgnoreDeprecations] attribute (see IgnoreDeprecations) can be used both on the test class and on the test method and is repeatable. It accepts an optional regular expression as its messagePattern argument. When a message pattern is specified, only deprecations with a message that matches the regular expression are ignored. This allows you to ignore the deprecation that a test expects while all other deprecations triggered by the test are still reported.

Testing deprecated functionality

When you deprecate functionality in your code, you want to keep tests for the deprecated code until it is actually removed. Use the #[IgnoreDeprecations] attribute together with expectUserDeprecationMessage() on tests that directly exercise deprecated functionality. This ensures the deprecated code still works as expected, the expected deprecation message is verified, and the test is not reported as having triggered a deprecation.

Wrappers for trigger_error()

Some libraries do not call PHP’s trigger_error() function directly to trigger E_USER_DEPRECATED issues, but use a wrapper such as the trigger_deprecation() function from symfony/deprecation-contracts or the Doctrine\Deprecations\Deprecation::trigger() method from doctrine/deprecations. Such a wrapper adds a stack frame: from PHP’s point of view, the deprecation is triggered inside the wrapper, not in the deprecated code that calls the wrapper.

The <deprecationTrigger> element (see The <deprecationTrigger> Element) can be used to configure functions and methods as deprecation triggers:

<source>
    <include>
        <directory>src</directory>
    </include>

    <deprecationTrigger>
        <function>trigger_deprecation</function>
        <method>Doctrine\Deprecations\Deprecation::trigger</method>
    </deprecationTrigger>
</source>

Configuring deprecation triggers has two effects on the processing of E_USER_DEPRECATED issues:

  • The location that is reported for a deprecation is the location where the configured wrapper was called, not the location inside the wrapper where trigger_error() was called.

  • The stack frames of configured wrappers are ignored when PHPUnit identifies how a deprecation was triggered, so that the deprecation is classified based on the code that uses the wrapper.

Functions and methods that are configured as deprecation triggers but are not defined when the configuration is loaded cause a test runner warning. The ignoreUndefinedTriggers attribute on the <deprecationTrigger> element can be used to silently ignore such entries, for instance when a deprecation trigger is provided by a library that is not installed in all environments.

Custom Issue Trigger Resolvers

While the <deprecationTrigger> element (see The <deprecationTrigger> Element) allows you to configure functions and methods that act as wrappers around trigger_error(), some frameworks require more sophisticated logic to determine the correct caller and callee for issue classification. For these cases, PHPUnit supports custom issue trigger resolvers.

A custom issue trigger resolver is a class that implements the PHPUnit\Runner\IssueTriggerResolver\Resolver interface:

<?php declare(strict_types=1);
namespace PHPUnit\Runner\IssueTriggerResolver;

interface Resolver
{
    /**
     * Return null to defer to the next resolver in the chain.
     *
     * @param list<array{file?: string, line?: int, class?: class-string, function?: string, type?: string, args?: list<mixed>}> $trace
     */
    public function resolve(array $trace, string $message): ?Resolution;
}

The resolve() method receives the filtered stack trace and the error message. It must return either:

  • A PHPUnit\Runner\IssueTriggerResolver\Resolution object that specifies the callee and caller file paths

  • null to defer to the next resolver in the chain

The Resolution class is constructed with two nullable string arguments: the callee file path and the caller file path:

new Resolution(
    $trace[1]['file'] ?? null, // callee (where the issue originated)
    $trace[2]['file'] ?? null, // caller (what called the code that triggered the issue)
);

PHPUnit uses these file paths to classify the issue as self, direct, indirect, or test by checking whether each file belongs to first-party code, third-party code, test code, or PHPUnit itself.

Implementing a custom resolver

Consider a framework that wraps trigger_error() in its own method:

namespace Vendor;

final class Framework
{
    public function trigger(): void
    {
        @trigger_error('framework deprecation', E_USER_DEPRECATED);
    }
}

Without a custom resolver, PHPUnit’s default resolver uses $trace[0] as the callee (the file containing trigger_error()) and $trace[1] as the caller. When the framework method is in the call stack, this means the framework file is the callee and the first-party code calling it is the caller, which may not accurately reflect the intended classification.

A custom resolver can inspect the stack trace and adjust the caller/callee accordingly:

<?php declare(strict_types=1);
namespace Vendor;

use PHPUnit\Runner\IssueTriggerResolver\Resolution;
use PHPUnit\Runner\IssueTriggerResolver\Resolver;

final class FrameworkResolver implements Resolver
{
    public function resolve(array $trace, string $message): ?Resolution
    {
        if (isset($trace[0]['file']) && str_contains($trace[0]['file'], 'Framework.php')) {
            return new Resolution(
                $trace[1]['file'] ?? null,
                $trace[2]['file'] ?? null,
            );
        }

        return null;
    }
}

This resolver checks whether the first frame in the stack trace is from the framework. If so, it shifts the caller/callee by one frame to skip the framework’s wrapper. If the condition does not match, it returns null to let the next resolver (or the default resolver) handle the issue.

Registering a custom resolver

Custom resolvers are registered in PHPUnit’s XML configuration file using the <issueTriggerResolvers> element inside <source> (see The <issueTriggerResolvers> Element):

<source>
    <include>
        <directory>src</directory>
    </include>

    <issueTriggerResolvers>
        <issueTriggerResolver className="Vendor\FrameworkResolver"/>
    </issueTriggerResolvers>
</source>

Multiple resolvers can be registered and are called in the order they are listed. The first resolver that returns a Resolution object wins. If all custom resolvers return null, PHPUnit falls back to its default resolver.

Custom Deprecation Filters

The configuration settings described above let you ignore deprecations based on where they are triggered (self, direct, or indirect). Sometimes you need finer-grained control, for instance to suppress a particular deprecation message that you cannot fix yet, while still reporting all other deprecations. For these cases, PHPUnit supports custom deprecation filters.

A custom deprecation filter is a class that implements the PHPUnit\Runner\DeprecationFilter interface:

<?php declare(strict_types=1);
namespace PHPUnit\Runner;

use PHPUnit\Event\Code\IssueTrigger\IssueTrigger;

interface DeprecationFilter
{
    /**
     * Return true to ignore the deprecation, false to let PHPUnit process it.
     */
    public function ignores(string $message, string $file, int $line, IssueTrigger $trigger): bool;
}

The ignores() method is called for every E_DEPRECATED and E_USER_DEPRECATED issue. It receives:

  • $message: the deprecation message

  • $file: the file in which the deprecation was triggered

  • $line: the line on which the deprecation was triggered

  • $trigger: an IssueTrigger object describing how the deprecation was triggered

The method must return true to ignore the deprecation or false to let PHPUnit process it.

A deprecation that is ignored by a filter is treated exactly like a deprecation that is ignored using the #[IgnoreDeprecations] attribute: it is not shown in the test runner’s progress output or in the deprecation summary, and it does not count towards failOnDeprecation (see The failOnDeprecation Attribute).

Implementing a custom deprecation filter

The following filter ignores a single deprecation message while letting PHPUnit process all other deprecations:

<?php declare(strict_types=1);
namespace App\Tests;

use function str_contains;
use PHPUnit\Event\Code\IssueTrigger\IssueTrigger;
use PHPUnit\Runner\DeprecationFilter;

final class MyDeprecationFilter implements DeprecationFilter
{
    public function ignores(string $message, string $file, int $line, IssueTrigger $trigger): bool
    {
        return str_contains($message, 'please ignore this deprecation');
    }
}

The $trigger argument can be used to make the decision depend on how the deprecation was triggered. For instance, $trigger->isIndirect() returns true when the deprecation was triggered by third-party code.

Registering a custom deprecation filter

Custom deprecation filters are registered in PHPUnit’s XML configuration file using the <deprecationFilters> element inside <source> (see The <deprecationFilters> Element):

<source>
    <include>
        <directory>src</directory>
    </include>

    <deprecationFilters>
        <deprecationFilter className="App\Tests\MyDeprecationFilter"/>
    </deprecationFilters>
</source>

Multiple filters can be registered. A deprecation is ignored as soon as one of the registered filters returns true for it.

Errors (E_USER_ERROR)

When test code, or code that is called from test code, triggers an E_USER_ERROR error then PHPUnit’s error handler turns this error into an exception: the execution of the test is aborted at the point where the error was triggered and the test is reported as errored.

The suppression operator (@) does not change this behavior: the test is aborted and reported as errored even when the E_USER_ERROR error was suppressed.

Triggering an E_USER_ERROR error using trigger_error() has been deprecated in PHP 8.4. Code that uses trigger_error($message, E_USER_ERROR) should be migrated to throw an exception (or call exit()).

Issues triggered outside of tests

Not all code runs while a test is running: test class files are loaded and data providers are executed while the test suite is being loaded, before the first test is run. PHPUnit’s error handler also processes issues that are triggered during this phase.

Issues that are triggered while a data provider runs are attributed to the tests that use the data provided by the data provider: they are reported as if they had been triggered by these tests.

Other issues that are triggered while the test suite is loaded, for instance a deprecation triggered when a test class file is loaded, cannot be attributed to a test. They are counted and reported separately as issues that were “triggered outside of tests”. The details for these issues are displayed using the same --display-* CLI options and displayDetailsOn* configuration settings that were discussed earlier in this chapter, and they are considered by the failOn* settings.

The bootstrap script (see The bootstrap Attribute) runs before PHPUnit’s error handler is registered: issues triggered by the bootstrap script are not processed by PHPUnit.

Error handlers registered by tests

PHPUnit’s test runner takes a snapshot of the registered error handlers before each test and compares it to the registered error handlers after the test:

  • When a test registers an error handler and does not remove it, the test is considered risky and reported with the message “Test code or tested code did not remove its own error handlers”. This check is not performed for tests that are run in a separate process.

  • When a test removes error handlers that it did not register, for instance PHPUnit’s own error handler, the test is considered risky and reported with the message “Test code or tested code removed error handlers other than its own”.

After each test, PHPUnit’s test runner restores the error handlers that were registered before the test so that subsequent tests are not affected.

Disabling PHPUnit’s error handler

When you want to test your own error handler or want to test that unit of code under test triggers an expected issue, for instance, the error handler registered by PHPUnit’s test runner will interfere with what you want to achieve.

The #[WithoutErrorHandler] attribute can be used in such a case to disable PHPUnit’s error handler for a test method.

Disabling PHPUnit’s error handler is also required when the code under test relies on error_get_last() or error_reporting(): while PHPUnit’s error handler is active, errors that it processes are not reported by error_get_last() and error_reporting() returns a masked value so that PHP itself does not report errors that PHPUnit’s error handler already processes.

Be aware that features of PHPUnit that rely on its error handler, meaning everything discussed in this chapter, do not work for a test method that uses the #[WithoutErrorHandler] attribute.