Repository: nelmio/NelmioApiDocBundle (PHP)

Issue:
[Bug]: #[OA\Tag] on subclass ignored when routes are inherited from abstract controller
### Version

5.0

### Description

When using an abstract controller to share route logic, and adding `#[OA\Tag]` to subclasses like `InvoiceController` or `CreditNoteController`, the tag is ignored in the documentation — routes appear under the default group.

---

### ✅ Expected behavior

Each subclass’s routes should appear under its own tag as defined by `#[OA\Tag]`, even if the methods are inherited.

---

### 🔧 Minimal Example

#### Abstract controller:

```php
abstract class AbstractDocumentController
{
    #[Route('', methods: ['GET'])]
    #[OA\Response(response: 200, description: 'List documents')]
    public function getDocuments(Request $request): JsonResponse
    {
        return new JsonResponse(['docs' => []]);
    }
}
```

#### Invoice controller:

```php
#[Route('/invoices')]
#[OA\Tag(name: 'Invoices')]
class InvoiceController extends AbstractDocumentController {}
```

#### CreditNote controller:

```php
#[Route('/credit-notes')]
#[OA\Tag(name: 'Credit Notes')]
class CreditNoteController extends AbstractDocumentController {}
```

---

### ❌ Actual Output in Swagger UI:

* **Tag: `default`**

  * `GET /invoices`
  * `GET /credit-notes`

---

### ✅ Expected Output in Swagger UI:

* **Tag: `Invoices`**

  * `GET /invoices`

* **Tag: `Credit Notes`**

  * `GET /credit-notes`

---

### Why this matters

* Prevents code duplication.
* Supports DRY design with abstract base classes.
* Aligns with how Symfony routing and class inheritance works.

---

### Suggested fix

Nelmio should apply `#[OA\Tag]` from the **concrete class** where the route is bound, not just where the method is defined.

### JSON OpenApi

_No response_

### Additional context

_No response_


Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 590fadfdf..3099be84a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,8 @@
 # CHANGELOG
 
+## 5.2.1
+Fixed a bug where using abstract controllers would ignore various attributes like ``#[OA\Tag]`` & ``#[Security]`` on child classes
+
 ## 5.2.0
 Made it possible to automatically generate security definitions based on the ``#[IsGranted]`` attribute.
 
diff --git a/src/DependencyInjection/NelmioApiDocExtension.php b/src/DependencyInjection/NelmioApiDocExtension.php
index 3834e216a..0642758f7 100644
--- a/src/DependencyInjection/NelmioApiDocExtension.php
+++ b/src/DependencyInjection/NelmioApiDocExtension.php
@@ -117,7 +117,6 @@ public function load(array $configs, ContainerBuilder $container): void
                 ->setArguments([
                     new Reference(\sprintf('nelmio_api_doc.routes.%s', $area)),
                     new Reference('nelmio_api_doc.controller_reflector'),
-                    new Reference('logger'),
                     $config['operation_id_generation'] instanceof OperationIdGeneration ?
                         $config['operation_id_generation'] :
                         OperationIdGeneration::from($config['operation_id_generation']),
diff --git a/src/Describer/OpenApiPhpDescriber.php b/src/Describer/OpenApiPhpDescriber.php
index bda531992..111303530 100644
--- a/src/Describer/OpenApiPhpDescriber.php
+++ b/src/Describer/OpenApiPhpDescriber.php
@@ -14,12 +14,12 @@
 use Nelmio\ApiDocBundle\Attribute\Operation;
 use Nelmio\ApiDocBundle\Attribute\Security;
 use Nelmio\ApiDocBundle\OpenApiPhp\Util;
+use Nelmio\ApiDocBundle\RouteDescriber\RouteDescriberTrait;
 use Nelmio\ApiDocBundle\Util\ControllerReflector;
 use Nelmio\ApiDocBundle\Util\SetsContextTrait;
 use OpenApi\Analysers\AttributeAnnotationFactory;
 use OpenApi\Annotations as OA;
 use OpenApi\Generator;
-use Psr\Log\LoggerInterface;
 use Symfony\Component\Routing\Route;
 use Symfony\Component\Routing\RouteCollection;
 
@@ -28,12 +28,12 @@ class_exists(OA\OpenApi::class);
 
 final class OpenApiPhpDescriber
 {
+    use RouteDescriberTrait;
     use SetsContextTrait;
 
     public function __construct(
         private readonly RouteCollection $routeCollection,
         private readonly ControllerReflector $controllerReflector,
-        private readonly LoggerInterface $logger,
         private readonly OperationIdGeneration $operationIdGeneration = OperationIdGeneration::ALWAYS_PREPEND,
     ) {
     }
@@ -42,32 +42,45 @@ public function describe(OA\OpenApi $api): void
     {
         $classAnnotations = [];
 
-        /** @var \ReflectionMethod $method */
-        foreach ($this->getMethodsToParse() as $method => [$path, $httpMethods, $routeName]) {
-            $declaringClass = $method->getDeclaringClass();
+        foreach ($this->getRoutesToParse() as $routeName => $route) {
+            $controller = $route->getDefault('_controller');
+            $reflectedMethod = $this->controllerReflector->getReflectionMethod($controller);
+            if (null === $reflectedMethod) {
+                continue;
+            }
+
+            $path = $this->normalizePath($route->getPath());
+            $supportedHttpMethods = $this->getSupportedHttpMethods($route);
+
+            $classReflector = $reflectedMethod->getDeclaringClass();
+            if (\is_array($controller) && method_exists(...$controller)) {
+                $classReflector = new \ReflectionClass($controller[0]);
+            } elseif (\is_string($controller) && false !== $i = strpos($controller, '::')) {
+                $classReflector = new \ReflectionClass(substr($controller, 0, $i));
+            }
 
             $path = Util::getPath($api, $path);
 
             $context = Util::createContext(['nested' => $path], $path->_context);
-            $context->namespace = $declaringClass->getNamespaceName();
-            $context->class = $declaringClass->getShortName();
-            $context->method = $method->name;
-            $context->filename = $method->getFileName();
+            $context->namespace = $classReflector->getNamespaceName();
+            $context->class = $classReflector->getShortName();
+            $context->method = $reflectedMethod->name;
+            $context->filename = $reflectedMethod->getFileName();
 
             $this->setContext($context);
 
-            if (!\array_key_exists($declaringClass->getName(), $classAnnotations)) {
-                $classAnnotations[$declaringClass->getName()] = $this->getAttributesAsAnnotation($declaringClass, $context);
+            if (!\array_key_exists($classReflector->getName(), $classAnnotations)) {
+                $classAnnotations[$classReflector->getName()] ??= $this->getAttributesAsAnnotation($classReflector, $context);
             }
 
-            $annotations = $this->getAttributesAsAnnotation($method, $context);
+            $annotations = $this->getAttributesAsAnnotation($reflectedMethod, $context);
 
             $implicitAnnotations = [];
             $mergeProperties = new \stdClass();
 
-            foreach (array_merge($annotations, $classAnnotations[$declaringClass->getName()]) as $annotation) {
+            foreach (array_merge($annotations, $classAnnotations[$classReflector->getName()]) as $annotation) {
                 if ($annotation instanceof Operation) {
-                    foreach ($httpMethods as $httpMethod) {
+                    foreach ($supportedHttpMethods as $httpMethod) {
                         $operation = Util::getOperation($path, $httpMethod);
                         $operation->mergeProperties($annotation);
                     }
@@ -76,7 +89,7 @@ public function describe(OA\OpenApi $api): void
                 }
 
                 if ($annotation instanceof OA\Operation) {
-                    if (!\in_array($annotation->method, $httpMethods, true)) {
+                    if (!\in_array($annotation->method, $supportedHttpMethods, true)) {
                         continue;
                     }
                     if (Generator::UNDEFINED !== $annotation->path && $path->path !== $annotation->path) {
@@ -92,7 +105,7 @@ public function describe(OA\OpenApi $api): void
                 if ($annotation instanceof Security) {
                     $annotation->validate();
 
-                    foreach ($httpMethods as $httpMethod) {
+                    foreach ($supportedHttpMethods as $httpMethod) {
                         $operation = Util::getOperation($path, $httpMethod);
 
                         if (Generator::UNDEFINED === $operation->security) {
@@ -127,13 +140,13 @@ public function describe(OA\OpenApi $api): void
                     && !$annotation instanceof OA\Parameter
                     && !$annotation instanceof OA\ExternalDocumentation
                 ) {
-                    throw new \LogicException(\sprintf('Using the annotation "%s" as a root annotation in "%s::%s()" is not allowed.', $annotation::class, $method->getDeclaringClass()->name, $method->name));
+                    throw new \LogicException(\sprintf('Using the annotation "%s" as a root annotation in "%s::%s()" is not allowed.', $annotation::class, $reflectedMethod->getDeclaringClass()->name, $reflectedMethod->name));
                 }
 
                 $implicitAnnotations[] = $annotation;
             }
 
-            foreach ($httpMethods as $httpMethod) {
+            foreach ($supportedHttpMethods as $httpMethod) {
                 $operation = Util::getOperation($path, $httpMethod);
                 if ([] !== $implicitAnnotations) {
                     $operation->merge($implicitAnnotations);
@@ -156,28 +169,9 @@ public function describe(OA\OpenApi $api): void
         $this->setContext(null);
     }
 
-    private function getMethodsToParse(): \Generator
+    private function getRoutesToParse(): \Generator
     {
-        foreach ($this->routeCollection->all() as $routeName => $route) {
-            if (!$route->hasDefault('_controller')) {
-                continue;
-            }
-            $controller = $route->getDefault('_controller');
-            $reflectedMethod = $this->controllerReflector->getReflectionMethod($controller);
-            if (null === $reflectedMethod) {
-                continue;
-            }
-            $path = $this->normalizePath($route->getPath());
-            $supportedHttpMethods = $this->getSupportedHttpMethods($route);
-            if ([] === $supportedHttpMethods) {
-                $this->logger->warning('None of the HTTP methods specified for path {path} are supported by swagger-ui, skipping this path', [
-                    'path' => $path,
-                ]);
-
-                continue;
-            }
-            yield $reflectedMethod => [$path, $supportedHttpMethods, $routeName];
-        }
+        yield from $this->routeCollection->all();
     }
 
     /**
@@ -196,15 +190,6 @@ private function getSupportedHttpMethods(Route $route): array
         return array_intersect($methods, $allMethods);
     }
 
-    private function normalizePath(string $path): string
-    {
-        if ('.{_format}' === substr($path, -10)) {
-            $path = substr($path, 0, -10);
-        }
-
-        return $path;
-    }
-
     /**
      * @param \ReflectionClass<object>|\ReflectionMethod $reflection
      *


Test patch:
diff --git a/tests/Functional/Controller/AbstractDocumentController.php b/tests/Functional/Controller/AbstractDocumentController.php
new file mode 100644
index 000000000..16866d23e
--- /dev/null
+++ b/tests/Functional/Controller/AbstractDocumentController.php
@@ -0,0 +1,25 @@
+<?php
+
+/*
+ * This file is part of the NelmioApiDocBundle package.
+ *
+ * (c) Nelmio
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Nelmio\ApiDocBundle\Tests\Functional\Controller;
+
+use OpenApi\Attributes as OA;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\Routing\Attribute\Route;
+
+abstract class AbstractDocumentController
+{
+    #[Route('', methods: ['GET'])]
+    #[OA\Response(response: 200, description: 'List documents')]
+    public function getDocuments(): JsonResponse
+    {
+    }
+}
diff --git a/tests/Functional/Controller/InvoiceDocumentController.php b/tests/Functional/Controller/InvoiceDocumentController.php
new file mode 100644
index 000000000..603905d49
--- /dev/null
+++ b/tests/Functional/Controller/InvoiceDocumentController.php
@@ -0,0 +1,21 @@
+<?php
+
+/*
+ * This file is part of the NelmioApiDocBundle package.
+ *
+ * (c) Nelmio
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Nelmio\ApiDocBundle\Tests\Functional\Controller;
+
+use OpenApi\Attributes as OA;
+use Symfony\Component\Routing\Attribute\Route;
+
+#[Route('/invoices', methods: 'GET')]
+#[OA\Tag(name: 'Invoices')]
+final class InvoiceDocumentController extends AbstractDocumentController
+{
+}
diff --git a/tests/Functional/ControllerTest.php b/tests/Functional/ControllerTest.php
index 876ee80cc..3cc45d777 100644
--- a/tests/Functional/ControllerTest.php
+++ b/tests/Functional/ControllerTest.php
@@ -574,6 +574,10 @@ function (RoutingConfigurator $routes) {
                 ],
             ],
         ];
+
+        yield 'Tag from class on inherited controller' => [
+            'InvoiceDocumentController',
+        ];
     }
 
     private static function getFixture(string $fixture): string
diff --git a/tests/Functional/Fixtures/InvoiceDocumentController.json b/tests/Functional/Fixtures/InvoiceDocumentController.json
new file mode 100644
index 000000000..71ca7b734
--- /dev/null
+++ b/tests/Functional/Fixtures/InvoiceDocumentController.json
@@ -0,0 +1,27 @@
+{
+    "openapi": "3.0.0",
+    "info": {
+        "title": "",
+        "version": "0.0.0"
+    },
+    "paths": {
+        "/invoices": {
+            "get": {
+                "tags": [
+                    "Invoices"
+                ],
+                "operationId": "get_nelmio_apidoc_tests_functional_invoicedocument_getdocuments",
+                "responses": {
+                    "200": {
+                        "description": "List documents"
+                    }
+                }
+            }
+        }
+    },
+    "tags": [
+        {
+            "name": "Invoices"
+        }
+    ]
+}
\ No newline at end of file