From 9b62bd8aca4b29684ca5a4b5a4baa626bad75327 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 1/5] 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 7a86d482..273466b3 100644 --- a/tests/Type/Symfony/data/messenger_handle_trait.php +++ b/tests/Type/Symfony/data/messenger_handle_trait.php @@ -111,3 +111,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 115785a2ea2093634adbb16234cffa5f80c47032 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 2/5] implementation --- extension.neon | 13 +++ src/Symfony/Configuration.php | 8 ++ ...rHandleTraitWrapperReturnTypeExtension.php | 105 ++++++++++++++++++ .../Symfony/data/messenger_handle_trait.php | 12 ++ 4 files changed, 138 insertions(+) create mode 100644 src/Type/Symfony/MessengerHandleTraitWrapperReturnTypeExtension.php diff --git a/extension.neon b/extension.neon index a38fd4bf..927d348d 100644 --- a/extension.neon +++ b/extension.neon @@ -11,6 +11,11 @@ parameters: constantHassers: true console_application_loader: null consoleApplicationLoader: null + messenger: + handleTraitWrappers: + # move that params to tests only + - MessengerHandleTrait\QueryBus::dispatch + - MessengerHandleTrait\QueryBus2::dispatch featureToggles: skipCheckGenericClasses: - Symfony\Component\Form\AbstractType @@ -115,6 +120,9 @@ parametersSchema: constantHassers: bool() console_application_loader: schema(string(), nullable()) consoleApplicationLoader: schema(string(), nullable()) + messenger: structure([ + handleTraitWrappers: listOf(string()) + ]) ]) services: @@ -214,6 +222,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/Symfony/Configuration.php b/src/Symfony/Configuration.php index 4c1f1a31..1eab587e 100644 --- a/src/Symfony/Configuration.php +++ b/src/Symfony/Configuration.php @@ -31,4 +31,12 @@ public function getConsoleApplicationLoader(): ?string return $this->parameters['consoleApplicationLoader'] ?? $this->parameters['console_application_loader'] ?? null; } + /** + * @return array + */ + public function getMessengerHandleTraitWrappers(): array + { + return $this->parameters['messenger']['handleTraitWrappers'] ?? []; + } + } diff --git a/src/Type/Symfony/MessengerHandleTraitWrapperReturnTypeExtension.php b/src/Type/Symfony/MessengerHandleTraitWrapperReturnTypeExtension.php new file mode 100644 index 00000000..a0ded049 --- /dev/null +++ b/src/Type/Symfony/MessengerHandleTraitWrapperReturnTypeExtension.php @@ -0,0 +1,105 @@ + */ + private $wrappers; + + public function __construct(MessageMapFactory $messageMapFactory, Configuration $configuration) + { + $this->messageMapFactory = $messageMapFactory; + $this->wrappers = $configuration->getMessengerHandleTraitWrappers(); + } + + 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 (!($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 273466b3..e1fb35a8 100644 --- a/tests/Type/Symfony/data/messenger_handle_trait.php +++ b/tests/Type/Symfony/data/messenger_handle_trait.php @@ -121,6 +121,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() { @@ -138,5 +147,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 888ba8575c7f66db2254656ab0fcd35734a651d4 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 3/5] implementation for interfaces --- extension.neon | 5 ++-- ...rHandleTraitWrapperReturnTypeExtension.php | 28 ++++++++++++++++--- .../Symfony/data/messenger_handle_trait.php | 20 ++++++++++--- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/extension.neon b/extension.neon index 927d348d..53310b16 100644 --- a/extension.neon +++ b/extension.neon @@ -13,9 +13,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 featureToggles: skipCheckGenericClasses: - Symfony\Component\Form\AbstractType diff --git a/src/Type/Symfony/MessengerHandleTraitWrapperReturnTypeExtension.php b/src/Type/Symfony/MessengerHandleTraitWrapperReturnTypeExtension.php index a0ded049..d2b0c314 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\Configuration; use PHPStan\Symfony\MessageMap; use PHPStan\Symfony\MessageMapFactory; @@ -19,7 +20,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 @@ -37,10 +38,14 @@ final class MessengerHandleTraitWrapperReturnTypeExtension implements Expression /** @var array */ private $wrappers; - public function __construct(MessageMapFactory $messageMapFactory, Configuration $configuration) + /** @var ReflectionProvider */ + private $reflectionProvider; + + public function __construct(MessageMapFactory $messageMapFactory, Configuration $configuration, ReflectionProvider $reflectionProvider) { $this->messageMapFactory = $messageMapFactory; $this->wrappers = $configuration->getMessengerHandleTraitWrappers(); + $this->reflectionProvider = $reflectionProvider; } public function getType(Expr $expr, Scope $scope): ?Type @@ -89,8 +94,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 e1fb35a8..7376eccb 100644 --- a/tests/Type/Symfony/data/messenger_handle_trait.php +++ b/tests/Type/Symfony/data/messenger_handle_trait.php @@ -119,9 +119,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 @@ -144,11 +153,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 3260fb2cfd0e223b1a1867d8dc56bd0024ce272f 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 4/5] 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 53310b16..2c724ba3 100644 --- a/extension.neon +++ b/extension.neon @@ -12,11 +12,7 @@ parameters: console_application_loader: null consoleApplicationLoader: null messenger: - handleTraitWrappers: - # todo move that params to tests only - - MessengerHandleTrait\QueryBus::dispatch - - MessengerHandleTrait\QueryBus::dispatch2 - - MessengerHandleTrait\QueryBusInterface::dispatch + handleTraitWrappers: [] featureToggles: skipCheckGenericClasses: - Symfony\Component\Form\AbstractType diff --git a/tests/Type/Symfony/extension-test.neon b/tests/Type/Symfony/extension-test.neon index f7dc1353..0849b813 100644 --- a/tests/Type/Symfony/extension-test.neon +++ b/tests/Type/Symfony/extension-test.neon @@ -2,3 +2,8 @@ parameters: symfony: console_application_loader: console_application_loader.php container_xml_path: container.xml + messenger: + handleTraitWrappers: + - MessengerHandleTrait\QueryBus::dispatch + - MessengerHandleTrait\QueryBus::dispatch2 + - MessengerHandleTrait\QueryBusInterface::dispatch From cdcaab5b6811f2ac0fe75764c37acebfb3edbc6c 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 5/5] updated README.md --- README.md | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/README.md b/README.md index 25a155c1..69cc669d 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ This extension provides following features: * Provides correct return type for `InputBag::get()` method based on the `$default` parameter. * Provides correct return type for `InputBag::all()` method based on the `$key` parameter. * Provides correct return types for `TreeBuilder` and `NodeDefinition` objects. +* Provides correct return type for Messenger `HandleTrait::handle()` method based on the message type. +* 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. * Optionally correct return types for `InputInterface::getArgument()`, `::getOption`, `::hasArgument`, and `::hasOption`. @@ -168,3 +170,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 +```