Summary
Implementing DenormalizerInterface with a single concrete return type now triggers method.childReturnType (and, if narrowing @param to compensate, method.childParameterType) errors that have no clean PHPDoc resolution. The errors are caused by the conditional return type that has been part of Symfony's DenormalizerInterface since 7.4. phpstan-symfony already ships a stub that resolves this — it just isn't loaded for Symfony ≥ 7.4.
Environment
phpstan/phpstan: 2.1.56
phpstan/phpstan-symfony: 2.0.18
symfony/serializer: 8.0.10
- PHP: 8.5
Reproduction
A typical per-type denormalizer:
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
class DateRangeNormalizer implements DenormalizerInterface
{
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): DateRange
{
return new DateRange(/* ... */);
}
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
{
return $type === DateRange::class;
}
public function getSupportedTypes(?string $format): array
{
return [DateRange::class => true];
}
}
PHPStan (level 8) reports:
Return type (DateRange) of method ...DateRangeNormalizer::denormalize() should be covariant
with return type (($type is class-string<object> ? object : mixed)) of method
Symfony\Component\Serializer\Normalizer\DenormalizerInterface::denormalize()
identifier: method.childReturnType
The natural attempt to fix it — narrowing the parameter to bind TObject:
/** @param class-string<DateRange> $type */
public function denormalize(mixed $data, string $type, ...): DateRange
produces a second error:
Parameter #2 $type (class-string<DateRange>) of method ...::denormalize() should be
contravariant with parameter $type (string) of method DenormalizerInterface::denormalize()
identifier: method.childParameterType
The two requirements (bind TObject to satisfy covariance, keep param as wide as string for contravariance) are mutually exclusive in PHPStan's LSP model. There is no PHPDoc that satisfies both.
Root cause
Since Symfony 7.4, DenormalizerInterface::denormalize() is annotated:
/**
* @template TObject of object
* @param class-string<TObject>|string $type
* @return ($type is class-string<TObject> ? TObject : mixed)
*/
The conditional return is great for callers of Serializer::denormalize($data, Foo::class) (return resolves to Foo), but for implementations of the interface — which are per-type and rely on supportsDenormalization() for runtime dispatch — the conditional cannot be honored in general by a single concrete return type.
phpstan-symfony already has the right workaround: a stub at stubs/Symfony/Component/Serializer/Normalizer/DenormalizerInterface.stub that flattens @return to mixed. That stub is loaded at src/Stubs/Symfony/StubFilesExtensionLoader.php:125:
if ($this->isInstalledVersionBelow('symfony/serializer', '7.4.0.0')) {
$files[] = $stubsDir . '/Symfony/Component/Serializer/Normalizer/DenormalizerInterface.stub';
}
The conditional return type has been present in the source since 7.4, so the gate excludes exactly the versions where the stub is needed.
Proposed fix
Either remove the version gate entirely, or bump it to cover currently-supported Symfony versions (e.g. '9.0.0.0'):
- if ($this->isInstalledVersionBelow('symfony/serializer', '7.4.0.0')) {
+ if ($this->isInstalledVersionBelow('symfony/serializer', '9.0.0.0')) {
$files[] = $stubsDir . '/Symfony/Component/Serializer/Normalizer/DenormalizerInterface.stub';
}
The existing stub content already does the right thing; it just isn't being applied.
Workaround in the meantime
Users can suppress the diagnostic per-method:
/** @phpstan-ignore method.childReturnType */
public function denormalize(...): DateRange
but that scales poorly across codebases with many normalizers.
Summary
Implementing
DenormalizerInterfacewith a single concrete return type now triggersmethod.childReturnType(and, if narrowing@paramto compensate,method.childParameterType) errors that have no clean PHPDoc resolution. The errors are caused by the conditional return type that has been part of Symfony'sDenormalizerInterfacesince 7.4.phpstan-symfonyalready ships a stub that resolves this — it just isn't loaded for Symfony ≥ 7.4.Environment
phpstan/phpstan: 2.1.56phpstan/phpstan-symfony: 2.0.18symfony/serializer: 8.0.10Reproduction
A typical per-type denormalizer:
PHPStan (level 8) reports:
The natural attempt to fix it — narrowing the parameter to bind
TObject:produces a second error:
The two requirements (bind
TObjectto satisfy covariance, keep param as wide asstringfor contravariance) are mutually exclusive in PHPStan's LSP model. There is no PHPDoc that satisfies both.Root cause
Since Symfony 7.4,
DenormalizerInterface::denormalize()is annotated:The conditional return is great for callers of
Serializer::denormalize($data, Foo::class)(return resolves toFoo), but for implementations of the interface — which are per-type and rely onsupportsDenormalization()for runtime dispatch — the conditional cannot be honored in general by a single concrete return type.phpstan-symfonyalready has the right workaround: a stub atstubs/Symfony/Component/Serializer/Normalizer/DenormalizerInterface.stubthat flattens@returntomixed. That stub is loaded atsrc/Stubs/Symfony/StubFilesExtensionLoader.php:125:The conditional return type has been present in the source since 7.4, so the gate excludes exactly the versions where the stub is needed.
Proposed fix
Either remove the version gate entirely, or bump it to cover currently-supported Symfony versions (e.g.
'9.0.0.0'):The existing stub content already does the right thing; it just isn't being applied.
Workaround in the meantime
Users can suppress the diagnostic per-method:
but that scales poorly across codebases with many normalizers.