From 5d0d3219bd46c4c6af6b4b930a7bd06475894021 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Nowak?= Date: Fri, 17 Jan 2025 12:59:01 +0100 Subject: [PATCH 01/11] test-cases for using messenger HandleTrait as QueryBus --- .../Symfony/data/messenger_handle_trait.php | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/Type/Symfony/data/messenger_handle_trait.php b/tests/Type/Symfony/data/messenger_handle_trait.php index 10f62a0c..a292141e 100644 --- a/tests/Type/Symfony/data/messenger_handle_trait.php +++ b/tests/Type/Symfony/data/messenger_handle_trait.php @@ -54,3 +54,32 @@ public function __invoke() assertType('mixed', $this->handle(new MultiHandlersForTheSameMessageQuery())); } } + +class QueryBus { + use HandleTrait; + + public function dispatch(object $query): mixed + { + return $this->handle($query); + } +} + +class Controller { + public function action() + { + $queryBus = new QueryBus(); + + assertType(RegularQueryResult::class, $queryBus->dispatch(new RegularQuery())); + + assertType('bool', $queryBus->dispatch(new BooleanQuery())); + assertType('int', $queryBus->dispatch(new IntQuery())); + assertType('float', $queryBus->dispatch(new FloatQuery())); + assertType('string', $queryBus->dispatch(new StringQuery())); + + assertType(TaggedResult::class, $queryBus->dispatch(new TaggedQuery())); + + // HandleTrait will throw exception in fact due to multiple handle methods/handlers per single query + assertType('mixed', $queryBus->dispatch(new MultiHandlesForInTheSameHandlerQuery())); + assertType('mixed', $queryBus->dispatch(new MultiHandlersForTheSameMessageQuery())); + } +} From 8a0a58d65a797031c17733aee65ee5a8d0164187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Nowak?= Date: Mon, 28 Jul 2025 08:25:03 +0200 Subject: [PATCH 02/11] implementation --- extension.neon | 13 +++ ...rHandleTraitWrapperReturnTypeExtension.php | 107 ++++++++++++++++++ .../Symfony/data/messenger_handle_trait.php | 12 ++ 3 files changed, 132 insertions(+) create mode 100644 src/Type/Symfony/MessengerHandleTraitWrapperReturnTypeExtension.php diff --git a/extension.neon b/extension.neon index 94b171bd..b368181e 100644 --- a/extension.neon +++ b/extension.neon @@ -8,6 +8,11 @@ parameters: containerXmlPath: null constantHassers: true consoleApplicationLoader: null + messenger: + handleTraitWrappers: + # move that params to tests only + - MessengerHandleTrait\QueryBus::dispatch + - MessengerHandleTrait\QueryBus2::dispatch stubFiles: - stubs/Psr/Cache/CacheException.stub - stubs/Psr/Cache/CacheItemInterface.stub @@ -96,6 +101,9 @@ parametersSchema: containerXmlPath: schema(string(), nullable()) constantHassers: bool() consoleApplicationLoader: schema(string(), nullable()) + messenger: structure([ + handleTraitWrappers: listOf(string()) + ]) ]) services: @@ -203,6 +211,11 @@ services: class: PHPStan\Type\Symfony\MessengerHandleTraitReturnTypeExtension tags: [phpstan.broker.expressionTypeResolverExtension] + # Messenger HandleTrait wrappers return type + - + class: PHPStan\Type\Symfony\MessengerHandleTraitWrapperReturnTypeExtension + tags: [phpstan.broker.expressionTypeResolverExtension] + # InputInterface::getArgument() return type - factory: PHPStan\Type\Symfony\InputInterfaceGetArgumentDynamicReturnTypeExtension diff --git a/src/Type/Symfony/MessengerHandleTraitWrapperReturnTypeExtension.php b/src/Type/Symfony/MessengerHandleTraitWrapperReturnTypeExtension.php new file mode 100644 index 00000000..39bdef45 --- /dev/null +++ b/src/Type/Symfony/MessengerHandleTraitWrapperReturnTypeExtension.php @@ -0,0 +1,107 @@ + */ + private array $wrappers; + + /** @param array{handleTraitWrappers: array}|null $messenger */ + public function __construct(MessageMapFactory $messageMapFactory, ?array $messenger) + { + $this->messageMapFactory = $messageMapFactory; + $this->wrappers = $messenger['handleTraitWrappers'] ?? []; + } + + public function getType(Expr $expr, Scope $scope): ?Type + { + if (!$this->isSupported($expr, $scope)) { + return null; + } + + $args = $expr->getArgs(); + if (count($args) !== 1) { + return null; + } + + $arg = $args[0]->value; + $argClassNames = $scope->getType($arg)->getObjectClassNames(); + + if (count($argClassNames) === 1) { + $messageMap = $this->getMessageMap(); + $returnType = $messageMap->getTypeForClass($argClassNames[0]); + + if (!is_null($returnType)) { + return $returnType; + } + } + + return null; + } + + /** + * @phpstan-assert-if-true =MethodCall $expr + */ + private function isSupported(Expr $expr, Scope $scope): bool + { + if ($this->wrappers === []) { + return false; + } + + if (!($expr instanceof MethodCall) || !($expr->name instanceof Identifier)) { + return false; + } + + $methodName = $expr->name->name; + $varType = $scope->getType($expr->var); + $classNames = $varType->getObjectClassNames(); + + if (count($classNames) !== 1) { + return false; + } + + $className = $classNames[0]; + $classMethodCombination = $className . '::' . $methodName; + + // Check if this class::method combination is configured + return in_array($classMethodCombination, $this->wrappers, true); + } + + private function getMessageMap(): MessageMap + { + if ($this->messageMap === null) { + $this->messageMap = $this->messageMapFactory->create(); + } + + return $this->messageMap; + } + +} diff --git a/tests/Type/Symfony/data/messenger_handle_trait.php b/tests/Type/Symfony/data/messenger_handle_trait.php index a292141e..26919145 100644 --- a/tests/Type/Symfony/data/messenger_handle_trait.php +++ b/tests/Type/Symfony/data/messenger_handle_trait.php @@ -64,6 +64,15 @@ public function dispatch(object $query): mixed } } +class QueryBus2 { + use HandleTrait; + + public function dispatch(object $query): mixed + { + return $this->handle($query); + } +} + class Controller { public function action() { @@ -81,5 +90,8 @@ public function action() // HandleTrait will throw exception in fact due to multiple handle methods/handlers per single query assertType('mixed', $queryBus->dispatch(new MultiHandlesForInTheSameHandlerQuery())); assertType('mixed', $queryBus->dispatch(new MultiHandlersForTheSameMessageQuery())); + + $queryBus2 = new QueryBus2(); + assertType(TaggedResult::class, $queryBus2->dispatch(new TaggedQuery())); } } From 05323a11bf99287725c699b1b493a9a7359093b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Nowak?= Date: Mon, 28 Jul 2025 08:42:53 +0200 Subject: [PATCH 03/11] implementation for interfaces --- extension.neon | 5 ++-- ...rHandleTraitWrapperReturnTypeExtension.php | 27 ++++++++++++++++--- .../Symfony/data/messenger_handle_trait.php | 20 +++++++++++--- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/extension.neon b/extension.neon index b368181e..8b1213aa 100644 --- a/extension.neon +++ b/extension.neon @@ -10,9 +10,10 @@ parameters: consoleApplicationLoader: null messenger: handleTraitWrappers: - # move that params to tests only + # todo move that params to tests only - MessengerHandleTrait\QueryBus::dispatch - - MessengerHandleTrait\QueryBus2::dispatch + - MessengerHandleTrait\QueryBus::dispatch2 + - MessengerHandleTrait\QueryBusInterface::dispatch stubFiles: - stubs/Psr/Cache/CacheException.stub - stubs/Psr/Cache/CacheItemInterface.stub diff --git a/src/Type/Symfony/MessengerHandleTraitWrapperReturnTypeExtension.php b/src/Type/Symfony/MessengerHandleTraitWrapperReturnTypeExtension.php index 39bdef45..fe9d177a 100644 --- a/src/Type/Symfony/MessengerHandleTraitWrapperReturnTypeExtension.php +++ b/src/Type/Symfony/MessengerHandleTraitWrapperReturnTypeExtension.php @@ -6,6 +6,7 @@ use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Identifier; use PHPStan\Analyser\Scope; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\Symfony\MessageMap; use PHPStan\Symfony\MessageMapFactory; use PHPStan\Type\ExpressionTypeResolverExtension; @@ -18,7 +19,7 @@ * Configurable extension for resolving return types of methods that internally use HandleTrait. * * Configured via PHPStan parameters under symfony.messenger.handleTraitWrappers with - * "Class::method" patterns, e.g.: + * "class::method" patterns, e.g.: * - App\Bus\QueryBus::dispatch * - App\Bus\QueryBus::query * - App\Bus\CommandBus::execute @@ -34,11 +35,14 @@ final class MessengerHandleTraitWrapperReturnTypeExtension implements Expression /** @var array */ private array $wrappers; + private ReflectionProvider $reflectionProvider; + /** @param array{handleTraitWrappers: array}|null $messenger */ - public function __construct(MessageMapFactory $messageMapFactory, ?array $messenger) + public function __construct(MessageMapFactory $messageMapFactory, ?array $messenger, ReflectionProvider $reflectionProvider) { $this->messageMapFactory = $messageMapFactory; $this->wrappers = $messenger['handleTraitWrappers'] ?? []; + $this->reflectionProvider = $reflectionProvider; } public function getType(Expr $expr, Scope $scope): ?Type @@ -91,8 +95,23 @@ private function isSupported(Expr $expr, Scope $scope): bool $className = $classNames[0]; $classMethodCombination = $className . '::' . $methodName; - // Check if this class::method combination is configured - return in_array($classMethodCombination, $this->wrappers, true); + // Check if this exact class::method combination is configured + if (in_array($classMethodCombination, $this->wrappers, true)) { + return true; + } + + // Check if any interface implemented by this class::method is configured + if ($this->reflectionProvider->hasClass($className)) { + $classReflection = $this->reflectionProvider->getClass($className); + foreach ($classReflection->getInterfaces() as $interface) { + $interfaceMethodCombination = $interface->getName() . '::' . $methodName; + if (in_array($interfaceMethodCombination, $this->wrappers, true)) { + return true; + } + } + } + + return false; } private function getMessageMap(): MessageMap diff --git a/tests/Type/Symfony/data/messenger_handle_trait.php b/tests/Type/Symfony/data/messenger_handle_trait.php index 26919145..56abcea9 100644 --- a/tests/Type/Symfony/data/messenger_handle_trait.php +++ b/tests/Type/Symfony/data/messenger_handle_trait.php @@ -62,9 +62,18 @@ public function dispatch(object $query): mixed { return $this->handle($query); } + + public function dispatch2(object $query): mixed + { + return $this->handle($query); + } +} + +interface QueryBusInterface { + public function dispatch(object $query): mixed; } -class QueryBus2 { +class QueryBusWithInterface implements QueryBusInterface { use HandleTrait; public function dispatch(object $query): mixed @@ -87,11 +96,14 @@ public function action() assertType(TaggedResult::class, $queryBus->dispatch(new TaggedQuery())); + assertType(RegularQueryResult::class, $queryBus->dispatch2(new RegularQuery())); + + $queryBusWithInterface = new QueryBusWithInterface(); + + assertType(RegularQueryResult::class, $queryBusWithInterface->dispatch(new RegularQuery())); + // HandleTrait will throw exception in fact due to multiple handle methods/handlers per single query assertType('mixed', $queryBus->dispatch(new MultiHandlesForInTheSameHandlerQuery())); assertType('mixed', $queryBus->dispatch(new MultiHandlersForTheSameMessageQuery())); - - $queryBus2 = new QueryBus2(); - assertType(TaggedResult::class, $queryBus2->dispatch(new TaggedQuery())); } } From 452e3da85ef07d840ea2d7c10f13d0f39e2269b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Nowak?= Date: Mon, 28 Jul 2025 08:49:24 +0200 Subject: [PATCH 04/11] move test parameters to test extension-test-neon file --- extension.neon | 6 +----- tests/Type/Symfony/extension-test.neon | 5 +++++ 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/extension.neon b/extension.neon index 8b1213aa..a00ebe37 100644 --- a/extension.neon +++ b/extension.neon @@ -9,11 +9,7 @@ parameters: constantHassers: true consoleApplicationLoader: null messenger: - handleTraitWrappers: - # todo move that params to tests only - - MessengerHandleTrait\QueryBus::dispatch - - MessengerHandleTrait\QueryBus::dispatch2 - - MessengerHandleTrait\QueryBusInterface::dispatch + handleTraitWrappers: [] stubFiles: - stubs/Psr/Cache/CacheException.stub - stubs/Psr/Cache/CacheItemInterface.stub diff --git a/tests/Type/Symfony/extension-test.neon b/tests/Type/Symfony/extension-test.neon index 0f1d9522..d1da1bcc 100644 --- a/tests/Type/Symfony/extension-test.neon +++ b/tests/Type/Symfony/extension-test.neon @@ -2,3 +2,8 @@ parameters: symfony: consoleApplicationLoader: console_application_loader.php containerXmlPath: container.xml + messenger: + handleTraitWrappers: + - MessengerHandleTrait\QueryBus::dispatch + - MessengerHandleTrait\QueryBus::dispatch2 + - MessengerHandleTrait\QueryBusInterface::dispatch \ No newline at end of file From 86e4507727c5140b9e2ff95e733e6d05901a2f30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Nowak?= Date: Mon, 28 Jul 2025 09:17:36 +0200 Subject: [PATCH 05/11] updated README.md --- README.md | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/README.md b/README.md index 8c38ba54..d7afdfd2 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ This extension provides following features: * Provides correct return type for `Extension::getConfiguration()` method. * Provides correct return type for `CacheInterface::get()` method based on the callback return type. * Provides correct return type for `BrowserKitAssertionsTrait::getClient()` method. +* Provides configurable return type resolution for methods that internally use Messenger `HandleTrait`. * Notifies you when you try to get an unregistered service from the container. * Notifies you when you try to get a private service from the container. * Notifies you when you access undefined console command arguments or options. @@ -180,3 +181,94 @@ Call the new env in your `console-application.php`: ```php $kernel = new \App\Kernel('phpstan_env', (bool) $_SERVER['APP_DEBUG']); ``` + +## Messenger HandleTrait Wrappers + +The extension provides advanced type inference for methods that internally use Symfony Messenger's `HandleTrait`. This feature is particularly useful for query bus implementations (in CQRS pattern) that use/wrap the `HandleTrait::handle()` method. + +### Configuration + +```neon +parameters: + symfony: + messenger: + handleTraitWrappers: + - App\Bus\QueryBus::dispatch + - App\Bus\QueryBus::execute + - App\Bus\QueryBusInterface::dispatch +``` + +### Message Handlers + +```php +use Symfony\Component\Messenger\Attribute\AsMessageHandler; + +// Product handler that returns Product +#[AsMessageHandler] +class GetProductQueryHandler +{ + public function __invoke(GetProductQuery $query): Product + { + return $this->productRepository->get($query->productId); + } +} +``` + +### PHP Examples + +```php +use Symfony\Component\Messenger\HandleTrait; +use Symfony\Component\Messenger\MessageBusInterface; + +// Basic query bus implementation +class QueryBus +{ + use HandleTrait; + + public function __construct(MessageBusInterface $messageBus) + { + $this->messageBus = $messageBus; + } + + public function dispatch(object $query): mixed + { + return $this->handle($query); // Return type will be inferred + } + + // Multiple methods per class example + public function execute(object $message): mixed + { + return $this->handle($message); + } +} + +// Interface-based configuration example +interface QueryBusInterface +{ + public function dispatch(object $query): mixed; +} + +class QueryBusWithInterface implements QueryBusInterface +{ + use HandleTrait; + + public function __construct(MessageBusInterface $queryBus) + { + $this->messageBus = $queryBus; + } + + public function dispatch(object $query): mixed + { + return $this->handle($query); + } +} + +// Usage examples with proper type inference +$query = new GetProductQuery($productId); +$queryBus = new QueryBus($messageBus); +$queryBusWithInterface = new QueryBusWithInterface($messageBus); + +$product = $queryBus->dispatch($query); // Returns: Product +$product2 = $queryBus->execute($query); // Returns: Product +$product3 = $queryBusWithInterface->dispatch($query); // Returns: Product +``` From 0ff50fcbd8413d5205ac58829569aa049f0bd3dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Nowak?= Date: Mon, 8 Sep 2025 07:29:32 +0200 Subject: [PATCH 06/11] updated README.md --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d7afdfd2..5e0895aa 100644 --- a/README.md +++ b/README.md @@ -232,20 +232,20 @@ class QueryBus public function dispatch(object $query): mixed { - return $this->handle($query); // Return type will be inferred + return $this->handle($query); // Return type will be inferred in calling code as query result } // Multiple methods per class example public function execute(object $message): mixed { - return $this->handle($message); + return $this->handle($message); // Return type will be inferred in calling code as query result } } // Interface-based configuration example interface QueryBusInterface { - public function dispatch(object $query): mixed; + public function dispatch(object $query): mixed; // Return type will be inferred in calling code as query result } class QueryBusWithInterface implements QueryBusInterface @@ -263,7 +263,7 @@ class QueryBusWithInterface implements QueryBusInterface } } -// Usage examples with proper type inference +// Examples of use with proper type inference $query = new GetProductQuery($productId); $queryBus = new QueryBus($messageBus); $queryBusWithInterface = new QueryBusWithInterface($messageBus); @@ -271,4 +271,5 @@ $queryBusWithInterface = new QueryBusWithInterface($messageBus); $product = $queryBus->dispatch($query); // Returns: Product $product2 = $queryBus->execute($query); // Returns: Product $product3 = $queryBusWithInterface->dispatch($query); // Returns: Product +// Without the feature all above query bus results would be default 'mixed'. ``` From 45b58a609320ca864567474ca365a8f5d524b248 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Nowak?= Date: Mon, 8 Sep 2025 08:50:05 +0200 Subject: [PATCH 07/11] test class name fix --- .../Symfony/data/messenger_handle_trait_with_subscriber.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Type/Symfony/data/messenger_handle_trait_with_subscriber.php b/tests/Type/Symfony/data/messenger_handle_trait_with_subscriber.php index 03456231..ef288304 100644 --- a/tests/Type/Symfony/data/messenger_handle_trait_with_subscriber.php +++ b/tests/Type/Symfony/data/messenger_handle_trait_with_subscriber.php @@ -56,7 +56,7 @@ public function __invoke(MultiHandlesForInTheSameHandlerQuery $query): bool } } -class HandleTraitClass { +class HandleTraitClassWithSubscriber { use HandleTrait; public function __invoke() From 618ff421021487fc7633208c858022cc41e00cb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Nowak?= Date: Tue, 17 Feb 2026 17:22:13 +0100 Subject: [PATCH 08/11] added union test-cases --- tests/Type/Symfony/data/messenger_handle_trait.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/Type/Symfony/data/messenger_handle_trait.php b/tests/Type/Symfony/data/messenger_handle_trait.php index 56abcea9..6654928e 100644 --- a/tests/Type/Symfony/data/messenger_handle_trait.php +++ b/tests/Type/Symfony/data/messenger_handle_trait.php @@ -50,6 +50,9 @@ public function __invoke() assertType(TaggedResult::class, $this->handle(new TaggedQuery())); + $randomQuery = rand(0, 1) ? new RegularQuery() : new TaggedQuery(); + assertType(RegularQueryResult::class . '|' . TaggedResult::class, $this->handle($randomQuery)); + // HandleTrait will throw exception in fact due to multiple handle methods/handlers per single query assertType('mixed', $this->handle(new MultiHandlersForTheSameMessageQuery())); } @@ -94,6 +97,9 @@ public function action() assertType('float', $queryBus->dispatch(new FloatQuery())); assertType('string', $queryBus->dispatch(new StringQuery())); + $randomQuery = rand(0, 1) ? new IntQuery() : new StringQuery(); + assertType('int|string', $queryBus->dispatch($randomQuery)); + assertType(TaggedResult::class, $queryBus->dispatch(new TaggedQuery())); assertType(RegularQueryResult::class, $queryBus->dispatch2(new RegularQuery())); @@ -102,6 +108,9 @@ public function action() assertType(RegularQueryResult::class, $queryBusWithInterface->dispatch(new RegularQuery())); + $randomQueryBus = rand(0, 1) ? $queryBus : $queryBusWithInterface; + assertType(RegularQueryResult::class, $randomQueryBus->dispatch(new RegularQuery())); + // HandleTrait will throw exception in fact due to multiple handle methods/handlers per single query assertType('mixed', $queryBus->dispatch(new MultiHandlesForInTheSameHandlerQuery())); assertType('mixed', $queryBus->dispatch(new MultiHandlersForTheSameMessageQuery())); From baec2a99d7ed086fd89ab24d269b32b7f976ac98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Nowak?= Date: Tue, 17 Feb 2026 17:30:47 +0100 Subject: [PATCH 09/11] support for handling union types --- ...essengerHandleTraitReturnTypeExtension.php | 39 ++++++++++++------- ...rHandleTraitWrapperReturnTypeExtension.php | 32 +++++++++++---- 2 files changed, 50 insertions(+), 21 deletions(-) diff --git a/src/Type/Symfony/MessengerHandleTraitReturnTypeExtension.php b/src/Type/Symfony/MessengerHandleTraitReturnTypeExtension.php index 1d1b7dec..a7685b99 100644 --- a/src/Type/Symfony/MessengerHandleTraitReturnTypeExtension.php +++ b/src/Type/Symfony/MessengerHandleTraitReturnTypeExtension.php @@ -10,6 +10,7 @@ use PHPStan\Symfony\MessageMapFactory; use PHPStan\Type\ExpressionTypeResolverExtension; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; use function count; use function is_null; @@ -30,26 +31,36 @@ public function __construct(MessageMapFactory $symfonyMessageMapFactory) public function getType(Expr $expr, Scope $scope): ?Type { - if ($this->isSupported($expr, $scope)) { - $args = $expr->getArgs(); - if (count($args) !== 1) { - return null; - } + if (!$this->isSupported($expr, $scope)) { + return null; + } + + $args = $expr->getArgs(); + if (count($args) !== 1) { + return null; + } - $arg = $args[0]->value; - $argClassNames = $scope->getType($arg)->getObjectClassNames(); + $arg = $args[0]->value; + $argClassNames = $scope->getType($arg)->getObjectClassNames(); - if (count($argClassNames) === 1) { - $messageMap = $this->getMessageMap(); - $returnType = $messageMap->getTypeForClass($argClassNames[0]); + if (count($argClassNames) === 0) { + return null; + } + + $messageMap = $this->getMessageMap(); + + $returnTypes = []; + foreach ($argClassNames as $argClassName) { + $returnType = $messageMap->getTypeForClass($argClassName); - if (!is_null($returnType)) { - return $returnType; - } + if (is_null($returnType)) { + return null; } + + $returnTypes[] = $returnType; } - return null; + return TypeCombinator::union(...$returnTypes); } private function getMessageMap(): MessageMap diff --git a/src/Type/Symfony/MessengerHandleTraitWrapperReturnTypeExtension.php b/src/Type/Symfony/MessengerHandleTraitWrapperReturnTypeExtension.php index fe9d177a..fa0c8862 100644 --- a/src/Type/Symfony/MessengerHandleTraitWrapperReturnTypeExtension.php +++ b/src/Type/Symfony/MessengerHandleTraitWrapperReturnTypeExtension.php @@ -11,6 +11,7 @@ use PHPStan\Symfony\MessageMapFactory; use PHPStan\Type\ExpressionTypeResolverExtension; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; use function count; use function in_array; use function is_null; @@ -59,16 +60,23 @@ public function getType(Expr $expr, Scope $scope): ?Type $arg = $args[0]->value; $argClassNames = $scope->getType($arg)->getObjectClassNames(); - if (count($argClassNames) === 1) { + if (count($argClassNames) === 0) { + return null; + } + + $returnTypes = []; + foreach ($argClassNames as $argClassName) { $messageMap = $this->getMessageMap(); - $returnType = $messageMap->getTypeForClass($argClassNames[0]); + $returnType = $messageMap->getTypeForClass($argClassName); - if (!is_null($returnType)) { - return $returnType; + if (is_null($returnType)) { + return null; } + + $returnTypes[] = $returnType; } - return null; + return TypeCombinator::union(...$returnTypes); } /** @@ -88,11 +96,21 @@ private function isSupported(Expr $expr, Scope $scope): bool $varType = $scope->getType($expr->var); $classNames = $varType->getObjectClassNames(); - if (count($classNames) !== 1) { + if (count($classNames) === 0) { return false; } - $className = $classNames[0]; + foreach ($classNames as $className) { + if (!$this->isClassMethodSupported($className, $methodName)) { + return false; + } + } + + return true; + } + + private function isClassMethodSupported(string $className, string $methodName): bool + { $classMethodCombination = $className . '::' . $methodName; // Check if this exact class::method combination is configured From 76a79c18f9c81947aa1c33cc97a6edca48306e9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Nowak?= Date: Wed, 18 Feb 2026 15:49:26 +0100 Subject: [PATCH 10/11] messenger params injected as service argument --- extension.neon | 2 ++ 1 file changed, 2 insertions(+) diff --git a/extension.neon b/extension.neon index a00ebe37..f8516ed0 100644 --- a/extension.neon +++ b/extension.neon @@ -212,6 +212,8 @@ services: - class: PHPStan\Type\Symfony\MessengerHandleTraitWrapperReturnTypeExtension tags: [phpstan.broker.expressionTypeResolverExtension] + arguments: + messenger: %symfony.messenger% # InputInterface::getArgument() return type - From c5829f9fc5cf3076e646cc0b49c61f0c8bf85646 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Nowak?= Date: Wed, 18 Feb 2026 16:19:12 +0100 Subject: [PATCH 11/11] fixed return mixed type wrong inference --- tests/Type/Symfony/data/messenger_handle_trait.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Type/Symfony/data/messenger_handle_trait.php b/tests/Type/Symfony/data/messenger_handle_trait.php index 6654928e..45651d4a 100644 --- a/tests/Type/Symfony/data/messenger_handle_trait.php +++ b/tests/Type/Symfony/data/messenger_handle_trait.php @@ -61,25 +61,25 @@ public function __invoke() class QueryBus { use HandleTrait; - public function dispatch(object $query): mixed + public function dispatch(object $query) { return $this->handle($query); } - public function dispatch2(object $query): mixed + public function dispatch2(object $query) { return $this->handle($query); } } interface QueryBusInterface { - public function dispatch(object $query): mixed; + public function dispatch(object $query); } class QueryBusWithInterface implements QueryBusInterface { use HandleTrait; - public function dispatch(object $query): mixed + public function dispatch(object $query) { return $this->handle($query); }