sebastianbergmann/phpunit · error · ClassIsFinalException

Class "%s" is declared "final" and cannot be doubled

Error message

Class "%s" is declared "final" and cannot be doubled

What it means

Test doubles are implemented as generated subclasses of the mocked type, so a class declared final cannot be extended; ReflectionClass::isFinal() on the target triggers ClassIsFinalException before any code generation. This guard exists to make the attempt fail loudly instead of producing a fatal error.

Source

Thrown at src/Framework/MockObject/Generator/Generator.php:394

        if (class_exists($_mockClassName['fullClassName'])) {
            $isClass = true;
        } elseif (interface_exists($_mockClassName['fullClassName'])) {
            $isInterface = true;
        }

        $class = $this->reflectClass($_mockClassName['fullClassName']);

        if ($class->isAnonymous()) {
            throw new ClassIsAnonymousException($_mockClassName['fullClassName']);
        }

        if ($class->isEnum()) {
            throw new ClassIsEnumerationException($_mockClassName['fullClassName']);
        }

        if ($class->isFinal()) {
            throw new ClassIsFinalException($_mockClassName['fullClassName']);
        }

        if ($class->isReadOnly()) {
            $isReadonly = true;
        }

        // @see https://github.com/sebastianbergmann/phpunit/issues/2995
        if ($isInterface && $class->implementsInterface(Throwable::class)) {
            $actualClassName        = Exception::class;
            $additionalInterfaces[] = $class->getName();
            $isInterface            = false;
            $class                  = $this->reflectClass($actualClassName);

            foreach ($this->userDefinedInterfaceMethods($_mockClassName['fullClassName']) as $method) {
                $methodName = $method->getName();

                if ($class->hasMethod($methodName)) {
                    $classMethod = $class->getMethod($methodName);

View on GitHub (pinned to f123cdb2a2)

Solutions

  1. Remove the final modifier from the class (preferred when tests are a legitimate reason; consider final-by-default exceptions for this class)
  2. Mock an interface the final class implements, and type-hint the interface in the SUT so the mock can be injected
  3. Extract the behavior you need to fake into a non-final collaborator class and mock that
  4. As a last resort for third-party final classes, wrap them in your own adapter and mock the adapter

Example fix

// before
final class PaymentGateway { public function charge(int $cents): bool { ... } }
$gateway = $this->createMock(PaymentGateway::class); // final -> exception

// after
interface PaymentGateway { public function charge(int $cents): bool; }
final class StripeGateway implements PaymentGateway { ... }
$gateway = $this->createMock(PaymentGateway::class);
Defensive patterns

Strategy: validation

Validate before calling

if ((new ReflectionClass($type))->isFinal()) {
    $type = class_implements($type)[0]
        ?? throw new RuntimeException("Cannot double final class {$type} without an interface");
}
$mock = $this->createMock($type);

Type guard

function isMockableType(string $type): bool
{
    if (!class_exists($type) && !interface_exists($type)) {
        return false;
    }
    $r = new ReflectionClass($type);
    return !$r->isAnonymous() && !$r->isEnum() && !$r->isFinal();
}

Prevention

When it happens

Trigger: createMock(FinalClass::class), createStub(FinalClass::class), or MockBuilder usage targeting any class declared with the final keyword; also classes that are final via @final annotation only will NOT trigger this (only real final), but readonly classes and many framework utility classes are genuinely final.

Common situations: Trying to mock final framework/vendor classes (e.g. Symfony's final utility classes, Carbon periods); mocking final value objects; production code marked final during a 'final by default' policy while tests still mock it; upgrading a dependency that newly marked a class final.

Related errors


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