Skip to content

Generic subclass not accepted where its own generic ancestor is expected, when the template argument contains mixed #15487

Closed
phpstan/phpstan-src
#6754

Description

@rogerbarnfather

Bug report

Generic subclass not accepted where its own generic ancestor is expected, when the template argument contains mixed (level 9+)

At level 9 and above, a class that declares its generic ancestor via @extends/@implements is not accepted where that exact ancestor type is expected, if the template argument is an array/iterable type containing mixed. The reported type and the expected type are spelled identically, and the template is correctly invariant, so the is not covariant tip does not apply.

Reproducing code

<?php

declare(strict_types=1);

/** @template T */
class Box {}

/** @extends Box<array<mixed>> */
final class ArrBox extends Box {}

/** @param Box<array<mixed>> $b */
function wantsArrBox(Box $b): void {}

function run(): void
{
    wantsArrBox(new ArrBox());
}

Run with phpstan analyse -l 9 (no other configuration).

Actual output

 ------ -------------------------------------------------------------------
  Line   repro.php
 ------ -------------------------------------------------------------------
  16     Parameter #1 $b of function wantsArrBox expects Box<array<mixed>>,
         ArrBox given.
         🪪  argument.type
         💡  Template type T on class Box is not covariant. Learn more:
         https://phpstan.org/blog/whats-up-with-template-covariant
 ------ -------------------------------------------------------------------

Expected output

No errors. ArrBox declares @extends Box<array<mixed>>, so it is a Box<array<mixed>>; no variance is involved.

Environment

  • PHPStan 2.3.1, PHP 8.5.11, macOS
  • Also reproduces on 2.2.17 and 1.12.34, and on 2.3.1 with bleedingEdge.neon included — so it looks long-standing rather than a recent regression.

Narrowing down

Level dependence. Clean at levels 6–8, reported at 9 and 10. Setting both checkExplicitMixed: false and checkImplicitMixed: false silences it; either flag alone is enough to bring it back.

Which template arguments trigger it (same Box snippet, only T varied):

T level 9 level 10
int, string, mixed, object, \stdClass, int|string, callable(int):bool ok ok
list<int>, array{a: int}, array<int, string>, iterable<int, int> ok ok
array<mixed>, list<mixed>, non-empty-array<mixed>, iterable<int> reported reported
array<string>, array<array-key, string> ok reported

Bare mixed is fine; mixed nested in an array/iterable value or key position is not. At level 10 an implicit array-key key is enough on its own (array<string> is reported, while the explicit array<int, string> is not).

Declared generic types are accepted; only the subclass is not. In the same file, a parameter already typed as the generic ancestor passes:

/** @param Box<array<mixed>> $declared */
function run(Box $declared): void
{
    wantsArrBox($declared);        // accepted
    wantsArrBox(new ArrBox());     // reported
}

So the comparison succeeds generic-to-generic and fails only when the subject type's ancestor has to be resolved from @extends/@implements.

Not specific to argument passing. The same pair produces return.type and assign.propertyType errors in the corresponding positions:

final class Holder
{
    /** @var Box<array<mixed>> */
    public Box $box;

    /** @return Box<array<mixed>> */
    public function get(): Box
    {
        return new ArrBox();        // return.type
    }

    public function set(): void
    {
        $this->box = new ArrBox();  // assign.propertyType
    }
}

Other variations that make no difference: final vs non-final subclass; an intermediate generic abstract class between the interface and the concrete class; @implements on a generic interface instead of @extends on a generic class; whether the template argument is written as array or array<mixed> on either side.

@template-covariant is not a workaround in the real-world case below, and would be wrong there: the template type appears in a parameter position, so PHPStan correctly rejects it with generics.variance.

Real-world case

Hit while implementing the specification pattern, where the candidate type is the template parameter and several specifications operate on arrays. This file reproduces on its own at level 9:

<?php

declare(strict_types=1);

/** @template TCandidate */
interface Specification
{
    /** @param TCandidate $candidate */
    public function isSatisfiedBy(mixed $candidate): bool;
}

/**
 * @template TCandidate
 *
 * @implements Specification<TCandidate>
 */
abstract readonly class AbstractSpecification implements Specification {}

/**
 * @template TCandidate
 *
 * @extends AbstractSpecification<TCandidate>
 */
final readonly class NotSpecification extends AbstractSpecification
{
    /** @param Specification<TCandidate> $s */
    public function __construct(private Specification $s) {}

    public function isSatisfiedBy(mixed $candidate): bool
    {
        return !$this->s->isSatisfiedBy($candidate);
    }
}

/**
 * @extends AbstractSpecification<array<mixed>>
 */
final readonly class ArrayMembersAreIdentical extends AbstractSpecification
{
    public function isSatisfiedBy(mixed $candidate): bool
    {
        return count(array_unique($candidate, SORT_REGULAR)) <= 1;
    }
}

function run(): void
{
    // Parameter #1 $s of class NotSpecification constructor expects
    // Specification<array<mixed>>, ArrayMembersAreIdentical given.
    $notIdentical = new NotSpecification(new ArrayMembersAreIdentical());
    $notIdentical->isSatisfiedBy([1, 2]);
}

Any combinator that composes array-typed specifications trips over this, so at level 9+ the pattern is only usable if every array specification is made generic over a bounded template it never actually varies:

/** @template TCandidate of array<mixed> @extends AbstractSpecification<TCandidate> */
final readonly class ArrayMembersAreIdentical extends AbstractSpecification { /* ... */ }

That does silence it — the construction site then yields a GenericObjectType rather than a plain object type, which is accepted — but it adds a template parameter that carries no information.

Code snippet that reproduces the problem

No response

Expected output

Expected: No errors. ArrBox declares @extends Box<array>, so it is a Box<array>; no variance is involved.

Did PHPStan help you today? Did it make you happy in any way?

No response

Activity

  1. mergeable commented on Oct 15, 2026

    @mergeable

    This bug report is missing a link to reproduction at phpstan.org/try.

    It will most likely be closed after manual review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions