sebastianbergmann/phpunit · error · MatcherAlreadyRegisteredException

Matcher with id <%s> is already registered

Error message

Matcher with id <%s> is already registered

What it means

Thrown by InvocationHandler::registerMatcher() when an expectation id is registered twice on the same double. Matcher ids are per-mock-object keys in matcherMap (used by ->after($id) ordering); registering the same id again would silently overwrite the first matcher, so PHPUnit refuses it.

Source

Thrown at src/Framework/MockObject/Runtime/InvocationHandler.php:109

     * @param non-empty-string $id
     */
    public function lookupMatcher(string $id): ?Matcher
    {
        return $this->matcherMap[$id] ?? null;
    }

    /**
     * Registers a matcher with the identification $id. The matcher can later be
     * looked up using lookupMatcher() to figure out if it has been invoked.
     *
     * @param non-empty-string $id
     *
     * @throws MatcherAlreadyRegisteredException
     */
    public function registerMatcher(string $id, Matcher $matcher): void
    {
        if (isset($this->matcherMap[$id])) {
            throw new MatcherAlreadyRegisteredException($id);
        }

        $this->matcherMap[$id] = $matcher;
    }

    /**
     * @throws TestDoubleSealedException
     */
    public function expects(InvocationOrder $rule): InvocationMocker|InvocationStubber
    {
        if ($this->sealed) {
            throw new TestDoubleSealedException;
        }

        $matcher = new Matcher($rule, $this->className);
        $this->addMatcher($matcher);

        if ($this->isMockObject) {

View on GitHub (pinned to f123cdb2a2)

Solutions

  1. Make ids unique per expectation on the same mock, e.g. 'step-1', 'step-2' or include the method name in the id.
  2. Delete the ->id() call on expectations that are never referenced by ->after().
  3. If ids come from a generator, ensure the generator state is not shared/reset between expectations.

Example fix

// before
$mock->expects($this->once())->method('load')->id('step');
$mock->expects($this->once())->method('save')->id('step');

// after
$mock->expects($this->once())->method('load')->id('load-step');
$mock->expects($this->once())->method('save')->id('save-step');
Defensive patterns

Strategy: validation

Validate before calling

$usedIds = [];
$id = 'step';
for ($i = 1; isset($usedIds[$id]); $i++) {
    $id = 'step_' . $i;
}
$usedIds[$id] = true;
$mock->expects($this->once())->method('a')->id($id);

Type guard

// ids are plain strings; guard against reuse per mock object
function uniqueId(string $base, array &$used): string
{
    $id = $base;
    $i = 1;

    while (isset($used[$id])) {
        $id = $base . '_' . $i++;
    }

    return $used[$id] = $id;
}

Try / catch

try {
    $mock->expects($this->once())->method('b')->id($id);
} catch (PHPUnit\Framework\MockObject\MatcherAlreadyRegisteredException $e) {
    // choose a different id or drop ->id() if nothing references it
}

Prevention

When it happens

Trigger: $mock->expects($this->once())->method('a')->id('step'); $mock->expects($this->once())->method('b')->id('step'); — the second ->id('step') triggers it. The lookup table lives on the specific mock object, so the same id on a different mock is fine.

Common situations: Copy-pasted expectation blocks in tests where the id string was not updated; ids generated from counters that reset (e.g. 'call' . ($i - $i)); test helpers that tag every expectation with a constant id like 'target'.

Related errors


AI-assisted analysis of sebastianbergmann/phpunit@f123cdb2a2 (2026-08-23). Data as JSON: /api/errors/02dfc78fe583f960. Report an issue: GitHub.