Skip to content

Commit 422837f

Browse files
SymfonyUsageProvider: Add AsMessageHandler Annotation for Symfony
1 parent f32746a commit 422837f

File tree

3 files changed

+123
-0
lines changed

3 files changed

+123
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ $ vendor/bin/phpstan
4242
- constructors, calls, factory methods
4343
- [`phpstan/phpstan-symfony`](https://github.com/phpstan/phpstan-symfony) with `containerXmlPath` must be used
4444
- `#[AsEventListener]` attribute
45+
- `#[AsMessageHandler]` attribute
4546
- `#[AsController]` attribute
4647
- `#[AsCommand]` attribute
4748
- `#[Required]` attribute

src/Provider/SymfonyUsageProvider.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,10 @@ protected function shouldMarkAsUsed(ReflectionMethod $method): ?string
363363
return 'Event listener method via #[AsEventListener] attribute';
364364
}
365365

366+
if ($this->isMessageHandlerMethodWithAsMessageHandlerAttribute($method)) {
367+
return 'Message handler method via #[AsMessageHandler] attribute';
368+
}
369+
366370
if ($this->isWorkflowEventListenerMethod($method)) {
367371
return 'Workflow event listener method via workflow attribute';
368372
}
@@ -500,6 +504,50 @@ protected function isEventListenerMethodWithAsEventListenerAttribute(ReflectionM
500504
|| $this->hasAttribute($method, 'Symfony\Component\EventDispatcher\Attribute\AsEventListener');
501505
}
502506

507+
protected function isMessageHandlerMethodWithAsMessageHandlerAttribute(ReflectionMethod $method): bool
508+
{
509+
$class = $method->getDeclaringClass();
510+
$methodName = $method->getName();
511+
512+
$compareMethodNames = static function (Reflector $classOrMethod, string $methodName, ?string $fallbackMethodName): bool {
513+
foreach ($classOrMethod->getAttributes('Symfony\Component\Messenger\Attribute\AsMessageHandler') as $attribute) {
514+
$arguments = $attribute->getArguments();
515+
516+
// Check named parameter first, then positional arguments at different positions
517+
$targetMethod = $arguments['method'] ?? $arguments[3] ?? $fallbackMethodName;
518+
519+
if ($targetMethod === $methodName) {
520+
return true;
521+
}
522+
}
523+
524+
return false;
525+
};
526+
527+
// Check if this method has the attribute directly (fallback to method name itself if no target specified)
528+
if ($compareMethodNames($method, $methodName, $methodName)) {
529+
return true;
530+
}
531+
532+
// Check class-level attributes (fallback to __invoke if no target specified)
533+
if ($compareMethodNames($class, $methodName, '__invoke')) {
534+
return true;
535+
}
536+
537+
// Check if any other method points to this method (only if explicitly specified)
538+
foreach ($class->getMethods() as $otherMethod) {
539+
if ($otherMethod->getName() === $methodName) {
540+
continue;
541+
}
542+
543+
if ($compareMethodNames($otherMethod, $methodName, null)) {
544+
return true;
545+
}
546+
}
547+
548+
return false;
549+
}
550+
503551
protected function isWorkflowEventListenerMethod(ReflectionMethod $method): bool
504552
{
505553
return $this->hasAttribute($method, 'Symfony\Component\Workflow\Attribute\AsAnnounceListener')

tests/Rule/data/providers/symfony-gte71.php

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
66
use Symfony\Component\DependencyInjection\Attribute\AutowireLocator;
7+
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
78
use Symfony\Component\Routing\Attribute\Route;
89
use Symfony\Component\Workflow\Attribute\AsAnnounceListener;
910
use Symfony\Component\Workflow\Attribute\AsCompletedListener;
@@ -100,3 +101,76 @@ public function deadMethod(): void // error: Unused Symfony\WorkflowEventListene
100101
{
101102
}
102103
}
104+
105+
// Test AsMessageHandler with default __invoke method
106+
#[AsMessageHandler]
107+
class MessageHandlerWithInvoke
108+
{
109+
public function __invoke(): void
110+
{
111+
}
112+
113+
public function deadMethod(): void // error: Unused Symfony\MessageHandlerWithInvoke::deadMethod
114+
{
115+
}
116+
}
117+
118+
// Test AsMessageHandler with custom method
119+
#[AsMessageHandler(method: 'handleMessage')]
120+
class MessageHandlerWithCustomMethod
121+
{
122+
public function handleMessage(): void
123+
{
124+
}
125+
126+
public function deadMethod(): void // error: Unused Symfony\MessageHandlerWithCustomMethod::deadMethod
127+
{
128+
}
129+
}
130+
131+
// Test AsMessageHandler on method level without parameters
132+
class MessageHandlerWithMethodAttribute
133+
{
134+
#[AsMessageHandler]
135+
public function handleDirectly(): void
136+
{
137+
}
138+
139+
public function deadMethod(): void // error: Unused Symfony\MessageHandlerWithMethodAttribute::deadMethod
140+
{
141+
}
142+
}
143+
144+
// Test AsMessageHandler on method level with method parameter (edge case)
145+
class MessageHandlerWithMethodLevelRedirect
146+
{
147+
#[AsMessageHandler(null, null, null, 'actualHandler')]
148+
public function annotatedMethod(): void // error: Unused Symfony\MessageHandlerWithMethodLevelRedirect::annotatedMethod
149+
{
150+
}
151+
152+
public function actualHandler(): void
153+
{
154+
}
155+
156+
public function deadMethod(): void // error: Unused Symfony\MessageHandlerWithMethodLevelRedirect::deadMethod
157+
{
158+
}
159+
}
160+
161+
// Test AsMessageHandler on method level with named parameter (edge case)
162+
class MessageHandlerWithMethodLevelNamedRedirect
163+
{
164+
#[AsMessageHandler(method: 'realHandler')]
165+
public function annotatedMethod(): void // error: Unused Symfony\MessageHandlerWithMethodLevelNamedRedirect::annotatedMethod
166+
{
167+
}
168+
169+
public function realHandler(): void
170+
{
171+
}
172+
173+
public function deadMethod(): void // error: Unused Symfony\MessageHandlerWithMethodLevelNamedRedirect::deadMethod
174+
{
175+
}
176+
}

0 commit comments

Comments
 (0)