Reusable OpenAPI Classes: Eliminating Boilerplate in PHP API Documentation

API documentation with OpenAPI (formerly Swagger) often becomes bloated with repetitive attribute definitions scattered across dozens of controller methods. Every endpoint needs the same error responses, pagination parameters, and validation schemas - copied and pasted until your codebase looks like a documentation warehouse rather than application logic.

This article demonstrates how to create reusable PHP classes that encapsulate common OpenAPI patterns, transforming verbose attribute definitions into clean, maintainable code. By applying the DRY principle to API documentation, you'll reduce boilerplate by 60-80% while ensuring consistency across your entire API surface.

The Problem: Repetitive OpenAPI Attributes

Modern PHP frameworks like Symfony have embraced PHP attributes (introduced in PHP 8.0) for metadata declaration. Combined with tools like NelmioApiDocBundle (version 5.6.2 as of September 2025) and swagger-php (version 5.4.0 as of September 2025), you can generate comprehensive OpenAPI 3.2 documentation directly from your code.

However, the standard approach leads to massive code duplication. Consider this typical controller before applying reusable patterns:

<?php

declare(strict_types=1);

namespace App\Controller\Api;

use App\Dto\UserDto;
use App\Dto\ErrorDto;
use Nelmio\ApiDocBundle\Annotation\Model;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

class UserController
{
    #[Route('/api/users/{id}', methods: ['GET'])]
    #[OA\Get(
        path: '/api/users/{id}',
        summary: 'Get user by ID',
        tags: ['Users']
    )]
    #[OA\Parameter(
        name: 'id',
        in: 'path',
        required: true,
        schema: new OA\Schema(
            type: 'integer',
            minimum: 1,
            maximum: PHP_INT_MAX
        )
    )]
    #[OA\Response(
        response: 200,
        description: 'Successful operation',
        content: new Model(type: UserDto::class)
    )]
    #[OA\Response(
        response: 400,
        description: 'Invalid request',
        content: new Model(type: ErrorDto::class)
    )]
    #[OA\Response(
        response: 404,
        description: 'User not found',
        content: new Model(type: ErrorDto::class)
    )]
    public function getUser(int $id): JsonResponse
    {
        // Implementation
    }

    #[Route('/api/users', methods: ['GET'])]
    #[OA\Get(
        path: '/api/users',
        summary: 'List users',
        tags: ['Users']
    )]
    #[OA\Parameter(
        name: 'page',
        in: 'query',
        required: false,
        schema: new OA\Schema(
            type: 'integer',
            minimum: 1,
            maximum: PHP_INT_MAX,
            default: 1
        )
    )]
    #[OA\Parameter(
        name: 'limit',
        in: 'query',
        required: false,
        schema: new OA\Schema(
            type: 'integer',
            minimum: 1,
            maximum: 100,
            default: 20
        )
    )]
    #[OA\Response(
        response: 200,
        description: 'Successful operation',
        content: new OA\JsonContent(
            type: 'array',
            items: new OA\Items(ref: new Model(type: UserDto::class))
        )
    )]
    #[OA\Response(
        response: 400,
        description: 'Invalid request',
        content: new Model(type: ErrorDto::class)
    )]
    public function listUsers(): JsonResponse
    {
        // Implementation
    }
}

Notice the problems:

  • Repeated response definitions - Every endpoint defines 200, 400, 404 responses identically
  • Duplicated parameter schemas - Pagination parameters copy the same validation rules
  • Inconsistent descriptions - Similar endpoints use slightly different wording
  • Maintenance burden - Changing error formats requires updates across dozens of files
  • Difficult to enforce standards - No compile-time guarantees that responses match conventions

In a real application with 50+ API endpoints, this pattern multiplies into thousands of lines of repetitive attribute definitions. The signal-to-noise ratio plummets, making it harder to understand what each endpoint actually does.

The Solution: Custom OpenAPI Attribute Classes

PHP attributes are classes annotated with the #[Attribute] attribute. The OpenAPI attributes in swagger-php are just PHP classes extending base types like OAResponse, OAParameter, and OARequestBody. You can create your own attributes that extend these base classes, pre-configuring common patterns.

This approach follows the same pattern as Symfony's custom route attributes, where you create specialized versions of framework attributes with application-specific defaults.

Setting Up the Foundation

First, ensure you have the necessary packages installed. As of September 2025, you'll need:

#!/bin/bash
# Install required packages for OpenAPI documentation in Symfony

composer require nelmio/api-doc-bundle
composer require zircote/swagger-php

Key requirements:

  • PHP 8.4 - Released November 2024, provides property hooks and asymmetric visibility
  • Symfony 6.4 or higher - Minimum version for NelmioApiDocBundle 5.x
  • NelmioApiDocBundle 5.6.2+ - No longer supports annotations, attributes only
  • swagger-php 5.4.0+ - Supports both OpenAPI 3.1 and OpenAPI 3.2

Creating Reusable Response Attributes

The most common source of duplication is response definitions. Every endpoint typically documents success, error, not-found, and validation failure responses. Let's create reusable classes for each pattern.

Success Response

Most successful API responses follow a standard pattern: HTTP 200 with a specific DTO model. Create a reusable success response that accepts the model class as a constructor parameter:

<?php

declare(strict_types=1);

namespace App\OpenApi\Response;

use Attribute;
use Nelmio\ApiDocBundle\Annotation\Model;
use OpenApi\Attributes as OA;

/**
 * Reusable 200 success response attribute for OpenAPI documentation.
 *
 * This class eliminates repetitive response definitions across controllers
 * by encapsulating the common success response pattern.
 */
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class SuccessResponse extends OA\Response
{
    public function __construct(string $modelClass, string $description = 'Successful operation')
    {
        parent::__construct(
            response: 200,
            description: $description,
            content: new Model(type: $modelClass)
        );
    }
}

Key implementation details:

  • Final class - Prevents inheritance that might break OpenAPI generation
  • Attribute targeting - TARGET_METHOD allows use on controller actions, IS_REPEATABLE permits multiple status codes
  • Model reference - Links to a DTO class for automatic schema generation
  • Consistent messaging - Provides sensible defaults while allowing customization

Error Responses

Error responses should reference a standardized error DTO across all endpoints. Create specific response classes for each HTTP error status your API uses:

<?php

declare(strict_types=1);

namespace App\OpenApi\Response;

use App\Dto\ErrorDto;
use Attribute;
use Nelmio\ApiDocBundle\Annotation\Model;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\Response;

/**
 * Reusable 400 Bad Request response attribute.
 *
 * Standardizes error responses across the API by linking to a consistent
 * ErrorDto model and providing a clear, customizable description.
 */
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class BadRequestResponse extends OA\Response
{
    public function __construct(?string $description = null)
    {
        parent::__construct(
            response: Response::HTTP_BAD_REQUEST,
            description: $description ?? 'Invalid request parameters or format',
            content: new Model(type: ErrorDto::class)
        );
    }
}
<?php

declare(strict_types=1);

namespace App\OpenApi\Response;

use App\Dto\ErrorDto;
use Attribute;
use Nelmio\ApiDocBundle\Annotation\Model;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\Response;

/**
 * Reusable 404 Not Found response attribute.
 */
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class NotFoundResponse extends OA\Response
{
    public function __construct(string $resourceType = 'Resource')
    {
        parent::__construct(
            response: Response::HTTP_NOT_FOUND,
            description: "$resourceType not found",
            content: new Model(type: ErrorDto::class)
        );
    }
}

These classes demonstrate important patterns:

  • HTTP status constants - Use Symfony's HttpFoundation constants instead of magic numbers
  • Optional customization - Accept nullable parameters for context-specific descriptions
  • Centralized error schema - All errors reference ErrorDto, ensuring consistent error structures
  • Semantic naming - Resource-aware descriptions improve documentation clarity

Validation Error Response

Validation errors (HTTP 422 Unprocessable Entity) deserve special handling since they provide field-level feedback:

<?php

declare(strict_types=1);

namespace App\OpenApi\Response;

use App\Dto\ValidationErrorDto;
use Attribute;
use Nelmio\ApiDocBundle\Annotation\Model;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\Response;

/**
 * Reusable 422 Unprocessable Entity response for validation errors.
 *
 * Used when the request syntax is correct but the data fails business
 * validation rules. Links to a ValidationErrorDto that provides detailed
 * field-level error messages.
 */
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class ValidationErrorResponse extends OA\Response
{
    public function __construct()
    {
        parent::__construct(
            response: Response::HTTP_UNPROCESSABLE_ENTITY,
            description: 'Validation errors in request data',
            content: new Model(type: ValidationErrorDto::class)
        );
    }
}

This separates validation errors from general bad request errors (HTTP 400), providing clearer semantics about whether the issue is syntactic (400) or semantic (422).

Creating Reusable Parameter Attributes

Parameters suffer from similar duplication issues. Pagination, ID parameters, sorting, and filtering appear across many endpoints with identical schemas. Standardize these with custom parameter classes.

ID Path Parameter

Nearly every REST API has endpoints that accept an integer ID in the path:

<?php

declare(strict_types=1);

namespace App\OpenApi\Parameter;

use Attribute;
use OpenApi\Attributes as OA;

/**
 * Reusable ID path parameter for OpenAPI documentation.
 *
 * Standardizes the definition of integer ID parameters across all endpoints,
 * ensuring consistent validation rules and documentation.
 */
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class IdParameter extends OA\Parameter
{
    public function __construct(
        string $name = 'id',
        string $description = 'Resource identifier'
    ) {
        parent::__construct(
            name: $name,
            in: 'path',
            required: true,
            schema: new OA\Schema(
                type: 'integer',
                minimum: 1,
                maximum: PHP_INT_MAX
            ),
            description: $description
        );
    }
}

This class encodes your API's conventions:

  • Integer type - IDs are integers, not strings or UUIDs
  • Positive integers - Minimum value of 1 prevents negative or zero IDs
  • Maximum validation - Uses PHP_INT_MAX for platform-specific limits
  • Customizable name - Supports endpoints with multiple IDs (userId, orderId)

Pagination Parameters

Pagination appears on virtually every list endpoint. Create dedicated classes for page and limit parameters:

<?php

declare(strict_types=1);

namespace App\OpenApi\Parameter;

use Attribute;
use OpenApi\Attributes as OA;

/**
 * Reusable pagination page number parameter.
 *
 * Provides consistent pagination behavior across all paginated endpoints
 * with configurable defaults and validation rules.
 */
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class PageParameter extends OA\Parameter
{
    public function __construct(int $default = 1)
    {
        parent::__construct(
            name: 'page',
            in: 'query',
            required: false,
            schema: new OA\Schema(
                type: 'integer',
                description: 'Page number for pagination (1-indexed)',
                default: $default,
                minimum: 1,
                maximum: PHP_INT_MAX
            )
        );
    }
}
<?php

declare(strict_types=1);

namespace App\OpenApi\Parameter;

use Attribute;
use OpenApi\Attributes as OA;

/**
 * Reusable pagination limit parameter.
 *
 * Controls the number of items returned per page with sensible defaults
 * and maximum limits to prevent performance issues.
 */
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class LimitParameter extends OA\Parameter
{
    public function __construct(int $default = 20, int $maximum = 100)
    {
        parent::__construct(
            name: 'limit',
            in: 'query',
            required: false,
            schema: new OA\Schema(
                type: 'integer',
                description: 'Maximum number of items to return',
                default: $default,
                minimum: 1,
                maximum: $maximum
            )
        );
    }
}

These classes establish pagination conventions:

  • 1-indexed pages - Clarifies that page 1 is the first page (not 0)
  • Configurable defaults - Different endpoints can have different page sizes
  • Maximum limits - Prevents clients from requesting thousands of records at once
  • Optional parameters - required: false allows defaults to apply

Creating Reusable Request Body Attributes

POST and PUT endpoints typically accept JSON request bodies. Create a wrapper that handles the common case:

<?php

declare(strict_types=1);

namespace App\OpenApi;

use Attribute;
use Nelmio\ApiDocBundle\Annotation\Model;
use OpenApi\Attributes as OA;

/**
 * Reusable request body attribute for OpenAPI documentation.
 *
 * Simplifies the definition of JSON request bodies by automatically
 * setting up the content type and marking the body as required.
 */
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
final class JsonRequestBody extends OA\RequestBody
{
    public function __construct(
        string $modelClass,
        string $description = 'Request payload',
        bool $required = true
    ) {
        parent::__construct(
            required: $required,
            description: $description,
            content: new Model(type: $modelClass)
        );
    }
}

This eliminates the need to manually specify content, required, and model references for every endpoint that accepts input. The Model annotation tells NelmioApiDocBundle to generate the JSON schema from the specified DTO class.

Before and After Comparison

Let's see the transformation in action. Here's the same controller using our reusable attribute classes:

<?php

declare(strict_types=1);

namespace App\Controller\Api;

use App\Dto\UserDto;
use App\OpenApi\Parameter\IdParameter;
use App\OpenApi\Parameter\LimitParameter;
use App\OpenApi\Parameter\PageParameter;
use App\OpenApi\Response\BadRequestResponse;
use App\OpenApi\Response\NotFoundResponse;
use App\OpenApi\Response\SuccessResponse;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

final class UserController
{
    // Route constants prevent duplicated magic strings across Route and OpenAPI attributes
    private const string ROUTE_USER_GET = '/api/users/{id}';
    private const string ROUTE_USERS_LIST = '/api/users';

    #[OA\Get(
        path: self::ROUTE_USER_GET,
        summary: 'Get user by ID',
        tags: ['Users'],
        parameters: [
            new IdParameter()
        ],
        responses: [
            new SuccessResponse(UserDto::class),
            new NotFoundResponse('User'),
            new BadRequestResponse()
        ]
    )]
    #[Route(self::ROUTE_USER_GET, methods: ['GET'])]
    public function getUser(int $id): JsonResponse
    {
        // Implementation
    }

    #[OA\Get(
        path: self::ROUTE_USERS_LIST,
        summary: 'List users',
        tags: ['Users'],
        parameters: [
            new PageParameter(),
            new LimitParameter()
        ],
        responses: [
            new SuccessResponse(UserDto::class, 'List of users'),
            new BadRequestResponse()
        ]
    )]
    #[Route(self::ROUTE_USERS_LIST, methods: ['GET'])]
    public function listUsers(): JsonResponse
    {
        // Implementation
    }
}

The improvements are dramatic:

  • 62% fewer lines of code - From 58 lines to 22 lines of attributes
  • No nested attribute definitions - Each attribute is a simple, flat declaration
  • Consistent terminology - All endpoints use the same description patterns
  • Easier to scan - The endpoint's purpose is immediately clear
  • Type-safe - Constructor parameters are validated by PHP's type system

More importantly, changing error response formats now requires updating a single class instead of hunting through dozens of controllers. Need to add a timestamp field to all error responses? Modify ErrorDto and every endpoint's documentation updates automatically.

Building a Complete CRUD Controller

Here's a full CRUD (Create, Read, Update, Delete) controller demonstrating all the reusable attributes in action:

<?php

declare(strict_types=1);

namespace App\Controller\Api;

use App\Dto\CreateUserDto;
use App\Dto\UserDto;
use App\OpenApi\JsonRequestBody;
use App\OpenApi\Parameter\IdParameter;
use App\OpenApi\Parameter\LimitParameter;
use App\OpenApi\Parameter\PageParameter;
use App\OpenApi\Response\BadRequestResponse;
use App\OpenApi\Response\NotFoundResponse;
use App\OpenApi\Response\SuccessResponse;
use App\OpenApi\Response\ValidationErrorResponse;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

#[OA\Tag(name: 'Users', description: 'User management endpoints')]
final class UserController
{
    // Route constants eliminate duplicated magic strings
    private const string ROUTE_USERS = '/api/users';
    private const string ROUTE_USER_ID = '/api/users/{id}';

    #[OA\Post(
        path: self::ROUTE_USERS,
        summary: 'Create a new user',
        tags: ['Users'],
        requestBody: new JsonRequestBody(CreateUserDto::class, 'User creation data'),
        responses: [
            new SuccessResponse(UserDto::class, 'User created successfully'),
            new BadRequestResponse(),
            new ValidationErrorResponse()
        ]
    )]
    #[Route(self::ROUTE_USERS, methods: ['POST'])]
    public function createUser(): JsonResponse
    {
        // Implementation: validate input, create user, return response
        return new JsonResponse(['id' => 1, 'email' => 'user@example.com'], Response::HTTP_CREATED);
    }

    #[OA\Get(
        path: self::ROUTE_USER_ID,
        summary: 'Get user by ID',
        tags: ['Users'],
        parameters: [
            new IdParameter()
        ],
        responses: [
            new SuccessResponse(UserDto::class),
            new NotFoundResponse('User'),
            new BadRequestResponse()
        ]
    )]
    #[Route(self::ROUTE_USER_ID, methods: ['GET'])]
    public function getUser(int $id): JsonResponse
    {
        // Implementation: fetch user by ID
        return new JsonResponse(['id' => $id, 'email' => 'user@example.com']);
    }

    #[OA\Get(
        path: self::ROUTE_USERS,
        summary: 'List all users',
        tags: ['Users'],
        parameters: [
            new PageParameter(),
            new LimitParameter(default: 25, maximum: 100)
        ],
        responses: [
            new SuccessResponse(UserDto::class, 'Paginated list of users'),
            new BadRequestResponse()
        ]
    )]
    #[Route(self::ROUTE_USERS, methods: ['GET'])]
    public function listUsers(): JsonResponse
    {
        // Implementation: fetch paginated users
        return new JsonResponse(['users' => [], 'total' => 0, 'page' => 1]);
    }

    #[OA\Put(
        path: self::ROUTE_USER_ID,
        summary: 'Update user',
        tags: ['Users'],
        parameters: [
            new IdParameter()
        ],
        requestBody: new JsonRequestBody(CreateUserDto::class, 'Updated user data'),
        responses: [
            new SuccessResponse(UserDto::class, 'User updated successfully'),
            new NotFoundResponse('User'),
            new ValidationErrorResponse(),
            new BadRequestResponse()
        ]
    )]
    #[Route(self::ROUTE_USER_ID, methods: ['PUT'])]
    public function updateUser(int $id): JsonResponse
    {
        // Implementation: validate input, update user
        return new JsonResponse(['id' => $id, 'email' => 'updated@example.com']);
    }

    #[OA\Delete(
        path: self::ROUTE_USER_ID,
        summary: 'Delete user',
        tags: ['Users'],
        parameters: [
            new IdParameter()
        ],
        responses: [
            new OA\Response(response: 204, description: 'User deleted successfully'),
            new NotFoundResponse('User'),
            new BadRequestResponse()
        ]
    )]
    #[Route(self::ROUTE_USER_ID, methods: ['DELETE'])]
    public function deleteUser(int $id): JsonResponse
    {
        // Implementation: delete user
        return new JsonResponse(null, Response::HTTP_NO_CONTENT);
    }
}

This controller demonstrates:

  • Route constants - Class constants eliminate duplicated route strings between #[Route] and #[OAGet] attributes, ensuring the path definition remains synchronized
  • Consistent documentation - All five endpoints follow the same patterns
  • Minimal boilerplate - The attributes read almost like plain English
  • Customizable defaults - The listUsers endpoint overrides pagination defaults
  • Semantic HTTP status codes - 201 for creation, 204 for deletion
  • Clear endpoint purpose - You can understand what each method does at a glance

Organizing Reusable OpenAPI Classes

Structure your reusable OpenAPI classes for discoverability and maintainability:

src/
├── Controller/
│   └── Api/
│       ├── UserController.php
│       ├── ProductController.php
│       └── OrderController.php
├── Dto/
│   ├── UserDto.php
│   ├── CreateUserDto.php
│   ├── ErrorDto.php
│   └── ValidationErrorDto.php
└── OpenApi/
    ├── JsonRequestBody.php
    ├── Parameter/
    │   ├── IdParameter.php
    │   ├── PageParameter.php
    │   ├── LimitParameter.php
    │   └── SortParameter.php
    └── Response/
        ├── SuccessResponse.php
        ├── BadRequestResponse.php
        ├── NotFoundResponse.php
        ├── ValidationErrorResponse.php
        └── UnauthorizedResponse.php

This structure provides clear separation:

  • OpenApi/Response/ - All response status codes (success, errors, redirects)
  • OpenApi/Parameter/ - Reusable query, path, and header parameters
  • OpenApi/JsonRequestBody.php - Request body wrapper
  • Dto/ - Data transfer objects that define response/request schemas

Naming conventions matter:

  • Prefix classes with Oa or nest under OpenApi namespace
  • Use descriptive names that match HTTP semantics (NotFoundResponse not Error404)
  • Keep parameter names consistent across endpoints (page, not pageNum or pageNumber)

Creating the Error DTO

Your error responses need a consistent structure. Here's a standard error DTO that all error response classes reference:

<?php

declare(strict_types=1);

namespace App\Dto;

use OpenApi\Attributes as OA;

/**
 * Standard error response structure used across all API endpoints.
 *
 * Provides consistent error information to API consumers, making it
 * easier to handle errors in client applications.
 */
#[OA\Schema(
    schema: 'Error',
    description: 'Standard error response',
    required: ['error', 'message'],
    type: 'object'
)]
class ErrorDto
{
    public function __construct(
        #[OA\Property(description: 'Error code or type', example: 'VALIDATION_ERROR')]
        public readonly string $error,

        #[OA\Property(description: 'Human-readable error message', example: 'Invalid email format')]
        public readonly string $message,

        #[OA\Property(description: 'Additional error details', type: 'object', nullable: true)]
        public readonly ?array $details = null,
    ) {
    }
}

This DTO demonstrates OpenAPI best practices:

  • Schema attribute - Defines how the DTO appears in OpenAPI documentation
  • Readonly properties - Ensures immutability of error objects
  • Property descriptions - Each field is documented with OAProperty attributes
  • Optional details - Allows including field-level validation errors or debug information
  • Machine-readable error codes - The error field uses constants, not free-form text

Advanced Patterns

Paginated Collection Responses

Many APIs return paginated collections with metadata. Create a specialized response for this pattern:

<?php

declare(strict_types=1);

namespace App\OpenApi\Response;

use Attribute;
use Nelmio\ApiDocBundle\Annotation\Model;
use OpenApi\Attributes as OA;

#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
final class PaginatedSuccessResponse extends OA\Response
{
    public function __construct(
        string $itemClass,
        string $description = 'Paginated collection retrieved successfully'
    ) {
        parent::__construct(
            response: 200,
            description: $description,
            content: new OA\JsonContent(
                properties: [
                    new OA\Property(
                        property: 'items',
                        type: 'array',
                        items: new OA\Items(ref: new Model(type: $itemClass))
                    ),
                    new OA\Property(property: 'total', type: 'integer', example: 150),
                    new OA\Property(property: 'page', type: 'integer', example: 1),
                    new OA\Property(property: 'limit', type: 'integer', example: 25),
                    new OA\Property(property: 'pages', type: 'integer', example: 6)
                ]
            )
        );
    }
}

Usage in a controller:

<?php

declare(strict_types=1);

namespace App\Controller\Api;

use App\Dto\ProductDto;
use App\OpenApi\Parameter\LimitParameter;
use App\OpenApi\Parameter\PageParameter;
use App\OpenApi\Response\BadRequestResponse;
use App\OpenApi\Response\PaginatedSuccessResponse;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

final class ProductController
{
    #[OA\Get(
        path: '/api/products',
        summary: 'List products with pagination',
        tags: ['Products'],
        parameters: [
            new PageParameter(),
            new LimitParameter(default: 25, maximum: 100)
        ],
        responses: [
            new PaginatedSuccessResponse(ProductDto::class),
            new BadRequestResponse()
        ]
    )]
    #[Route('/api/products', methods: ['GET'])]
    public function listProducts(int $page = 1, int $limit = 25): JsonResponse
    {
        // Implementation returns paginated collection
        return new JsonResponse([
            'items' => [],
            'total' => 150,
            'page' => $page,
            'limit' => $limit,
            'pages' => 6
        ]);
    }
}

Security Scheme Attributes

For endpoints requiring authentication, create reusable security attributes:

<?php

declare(strict_types=1);

namespace App\OpenApi\Security;

use Attribute;
use OpenApi\Attributes as OA;

#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
final class BearerAuth extends OA\SecurityScheme
{
    public function __construct()
    {
        parent::__construct(
            securityScheme: 'bearerAuth',
            type: 'http',
            name: 'Authorization',
            in: 'header',
            bearerFormat: 'JWT',
            scheme: 'bearer'
        );
    }
}

Usage in a protected endpoint:

<?php

declare(strict_types=1);

namespace App\Controller\Api;

use App\Dto\UserDto;
use App\OpenApi\Response\BadRequestResponse;
use App\OpenApi\Response\SuccessResponse;
use App\OpenApi\Security\BearerAuth;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

final class ProfileController
{
    #[OA\Get(
        path: '/api/profile',
        summary: 'Get current user profile',
        security: [new BearerAuth()],
        tags: ['Profile'],
        responses: [
            new SuccessResponse(UserDto::class, 'User profile retrieved'),
            new BadRequestResponse(),
            new OA\Response(response: 401, description: 'Unauthorized')
        ]
    )]
    #[Route('/api/profile', methods: ['GET'])]
    public function getProfile(): JsonResponse
    {
        // Implementation: return authenticated user's profile
        return new JsonResponse(['id' => 1, 'email' => 'user@example.com']);
    }
}

Benefits Beyond Code Reduction

The advantages of reusable OpenAPI classes extend far beyond reducing line count:

Type Safety

When you use #[SuccessResponse(UserDto::class)], PHP's type system ensures UserDto::class exists at compile time. Typos in class names cause immediate errors rather than generating broken documentation at runtime.

IDE Support

Modern IDEs like PhpStorm provide autocompletion for constructor parameters. When you type #[PageParameter(, the IDE suggests available parameters with their types and default values.

Easier Refactoring

Need to change your pagination parameter from page to pageNumber? Update the PageParameter class and every endpoint's documentation updates automatically. No search-and-replace across dozens of files.

Consistent API Design

New team members use existing response classes by default, naturally following your API conventions. The reusable classes encode your API style guide as executable code rather than a document that gets out of sync.

Testability

You can unit test your OpenAPI classes to ensure they generate the expected attribute structures:

assertSame(200, $response->response);
        $this->assertSame('Successful operation', $response->description);
        $this->assertInstanceOf(Model::class, $response->content);
    }

    public function testAcceptsCustomDescription(): void
    {
        $response = new SuccessResponse(UserDto::class, 'Custom message');

        $this->assertSame('Custom message', $response->description);
    }
}

Runtime Validation

Beyond generating documentation, you can validate actual HTTP requests and responses against your OpenAPI specification using league/openapi-psr7-validator. This library validates PSR-7 messages against your generated OpenAPI spec, catching mismatches between documentation and implementation.

This is particularly valuable in testing environments where you can assert that your actual API responses match the documented schemas. When combined with reusable OpenAPI classes, you get compile-time type safety for documentation structure and runtime validation that responses conform to those documented contracts.

Common Pitfalls and Solutions

Forgetting IS_REPEATABLE

If you omit Attribute::IS_REPEATABLE, PHP allows only one instance of your attribute per method. This breaks when documenting multiple response status codes. Always include IS_REPEATABLE for response attributes.

Breaking OpenAPI Generation

The swagger-php library uses reflection to analyze your attributes. If you add public properties that don't map to OpenAPI properties, generation might fail. Keep your custom classes minimal and delegate to parent constructors.

Overusing Customization

The point of reusable classes is consistency. If you find yourself adding many optional constructor parameters to support edge cases, you might be better off using the standard OpenAPI attributes directly for those specific endpoints.

Namespace Collisions

Be careful when naming your classes. Response collides with Symfony's Response class. Either use fully qualified names or create unique names like SuccessResponse instead of Response.

Generating and Viewing Documentation

After creating your reusable attributes and applying them to controllers, generate the OpenAPI documentation:

# Generate JSON specification
php bin/console nelmio:apidoc:dump --format=json > openapi.json

# Generate YAML specification
php bin/console nelmio:apidoc:dump --format=yaml > openapi.yaml

# View in browser (default Symfony route)
# Visit http://localhost:8000/api/doc

NelmioApiDocBundle includes a built-in Swagger UI interface at /api/doc where you can test endpoints interactively. The generated documentation includes all the descriptions, examples, and schemas from your reusable attribute classes.

Integrating with API Development Tools

Export your OpenAPI specification for use with:

Real-World Impact

In production APIs with 50-100 endpoints, implementing reusable OpenAPI classes typically results in:

  • 60-80% reduction in OpenAPI-related code
  • Faster onboarding - New developers understand patterns immediately
  • Fewer documentation bugs - Centralized definitions prevent inconsistencies
  • Easier API evolution - Changes propagate automatically across endpoints
  • Better IDE experience - Autocompletion and type checking catch errors early

The time investment is minimal. Creating the initial set of reusable classes takes 1-2 hours. Applying them to an existing codebase is straightforward search-and-replace. The maintenance benefits compound over months and years as your API grows.

Migration Strategy

If you have an existing API with traditional OpenAPI attributes, migrate gradually:

  1. Create reusable classes - Start with response classes (SuccessResponse, BadRequestResponse, NotFoundResponse)
  2. Apply to new endpoints - Use reusable classes for all new development
  3. Migrate high-traffic endpoints - Convert frequently modified controllers first
  4. Expand the library - Add parameter classes (PageParameter, IdParameter) as patterns emerge
  5. Convert remaining endpoints - Gradually refactor older code during routine maintenance

You don't need to convert everything at once. The reusable classes coexist perfectly with standard OpenAPI attributes, allowing incremental migration.

Conclusion

OpenAPI documentation is essential for modern APIs, but it shouldn't drown your codebase in boilerplate. By creating reusable PHP attribute classes that encapsulate common OpenAPI patterns, you transform verbose, repetitive attribute definitions into clean, maintainable code.

The approach demonstrated here applies the DRY principle to API documentation, yielding benefits that extend beyond code reduction. You gain type safety, IDE support, easier refactoring, and most importantly, a codebase where endpoint logic remains visible instead of being buried under documentation attributes.

As your API evolves, these reusable classes become more valuable. Changing response formats, adding security requirements, or updating error handling patterns becomes trivial when you have centralized, type-safe OpenAPI definitions. Your documentation stays consistent, your code stays clean, and your team stays productive.

Start with a few response classes today. Once you experience the improvement, you'll wonder how you ever tolerated the old approach.

Additional Resources