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 underOpenApi
namespace - Use descriptive names that match HTTP semantics (
NotFoundResponse
notError404
) - Keep parameter names consistent across endpoints (
page
, notpageNum
orpageNumber
)
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:
- Postman - Import the JSON/YAML to generate a collection
- Insomnia - Load the specification for API testing
- Stoplight Studio - Visual API design and documentation
- OpenAPI Generator - Generate client libraries in multiple languages
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:
- Create reusable classes - Start with response classes (
SuccessResponse
,BadRequestResponse
,NotFoundResponse
) - Apply to new endpoints - Use reusable classes for all new development
- Migrate high-traffic endpoints - Convert frequently modified controllers first
- Expand the library - Add parameter classes (
PageParameter
,IdParameter
) as patterns emerge - 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
- OpenAPI Specification - Official specification repository
- NelmioApiDocBundle - Symfony bundle for OpenAPI generation
- swagger-php - PHP library for OpenAPI annotations and attributes
- PHP Attributes - Official PHP manual on attributes
- Symfony Documentation - Comprehensive framework documentation
- Swagger UI - Interactive API documentation interface
- PHP-FIG PSR Standards - PHP Standards Recommendations including PSR-7 (HTTP Messages)