Repository navigation
Generic subclass not accepted where its own generic ancestor is expected, when the template argument contains mixed #15487
Description
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.neonincluded — 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
mergeable commentedon Oct 15, 2026
This bug report is missing a link to reproduction at phpstan.org/try.
It will most likely be closed after manual review.