API Platform and OpenAPI: What Is Automatically Good and What You Must Override
AI generated
{ }
GET
API Platform · OpenAPI · Symfony · PHP
API Platform and OpenAPI: What Is Automatically Good and What You Must Override
Strengths and blind spots of automatic spec generation

API Platform generates OpenAPI documentation from PHP classes, which saves hours of manual work. But schema names with internal counters, missing examples, rudimentary security definitions and undocumented error cases show where the automation promise reaches its limits. This article shows concretely what works well, and what needs to be overridden.

20 min read API Platform · OpenApiFactory · Schema · Examples · Security PHP 8.4 · Symfony 7 · API Platform 3.x

1. What API Platform gets automatically right

API Platform solves the central problem of API documentation: it stays in sync with the code. Anyone who adds a new PHP attribute to a resource class, or declares a new endpoint via #[ApiResource], sees the change immediately in the generated OpenAPI specification. There is no separate documentation file that could be forgotten. This automatic synchronicity is the decisive advantage over manually maintained OpenAPI files.

Concretely, API Platform automatically and correctly generates: all CRUD endpoints with the right HTTP methods, request body schemas from declared properties, required-field markers from PHPDoc and validation attributes, content type headers, and the Swagger UI for interactive testing. For simple CRUD APIs, the automatic documentation is often sufficient. The problems begin when the API becomes more complex: different response structures depending on context, adjusted field names, authentication flows, and precise error documentation.

2. Schema names: cleaning up internal counters and generic identifiers

The first problem developers run into with API Platform and OpenAPI is the automatically generated schema names. API Platform produces names like User.jsonld, User-user.read, User-user.write or even User-read.1 with appended counters. These names are unreadable for developers and code generators alike. Frontend teams generating TypeScript clients from the spec get type names like UserJsonld or UserRead1, unworkable in any large project.

The solution lies in explicit schema names via attributes. With #[ApiResource(shortName: 'User')] the base name can be set. For serializer groups, API Platform has offered the openapi parameter in #[ApiResource] since version 3.1, through which schema names for read and write operations can be defined explicitly. For complex cases, implementing your own OpenApiFactoryInterface decorator is the most direct route: the decorator intervenes in the generated spec and renames schemas according to a defined convention.


<?php
// src/ApiResource/UserResource.php
declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use ApiPlatform\OpenApi\Model;
use Symfony\Component\Serializer\Annotation\Groups;

#[ApiResource(
    shortName: 'User',
    operations: [
        new Get(
            uriTemplate: '/users/{id}',
            openapi: new Model\Operation(
                summary: 'Retrieve a single user',
                description: 'Returns a user by ID. Requires authentication.',
                tags: ['Users'],
            )
        ),
        new GetCollection(
            uriTemplate: '/users',
            openapi: new Model\Operation(
                summary: 'List all users',
                tags: ['Users'],
            )
        ),
        new Post(
            uriTemplate: '/users',
            openapi: new Model\Operation(
                summary: 'Create a new user',
                tags: ['Users'],
            )
        ),
    ],
    normalizationContext: ['groups' => ['user:read']],
    denormalizationContext: ['groups' => ['user:write']],
)]
class UserResource
{
    public ?int $id = null;

    #[Groups(['user:read', 'user:write'])]
    public string $email = '';

    #[Groups(['user:read', 'user:write'])]
    public string $name = '';

    #[Groups(['user:read'])]
    public string $role = 'viewer';

    #[Groups(['user:read'])]
    public bool $active = true;

    #[Groups(['user:read'])]
    public \DateTimeImmutable $createdAt;
}

3. Response examples: why they are missing and how to add them

API Platform automatically generates request and response schemas, but no examples blocks in the OpenAPI specification. That is a critical shortcoming, because examples are the foundation for mock servers (Prism, WireMock) and interactive documentation (Swagger UI, Redoc). Without examples, frontend developers cannot use a mock server, and Swagger UI only shows the abstract schema, not a concrete example JSON.

Examples can be added in two ways: via #[ApiProperty] attributes on individual fields with the openapiContext parameter, or via the OpenApiFactory decorator at the operation level. The latter is more powerful because it defines complete example objects instead of individual field values. The factory decorator iterates over all operations in the generated spec and adds examples as needed, without replacing the existing generation logic.


<?php
// src/OpenApi/OpenApiFactory.php
declare(strict_types=1);

namespace App\OpenApi;

use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface;
use ApiPlatform\OpenApi\Model;
use ApiPlatform\OpenApi\OpenApi;

/**
 * Decorator that adds examples, security schemes and error documentation
 * to the automatically generated OpenAPI specification.
 */
final class OpenApiFactory implements OpenApiFactoryInterface
{
    public function __construct(
        private readonly OpenApiFactoryInterface $decorated,
    ) {}

    public function __invoke(array $context = []): OpenApi
    {
        $openApi = ($this->decorated)($context);

        $this->addUserExamples($openApi);
        $this->addSecuritySchemes($openApi);
        $this->addGlobalErrorResponses($openApi);

        return $openApi;
    }

    private function addUserExamples(OpenApi $openApi): void
    {
        $paths = $openApi->getPaths();

        foreach ($paths->getPaths() as $path => $pathItem) {
            if (!str_starts_with($path, '/users')) {
                continue;
            }

            $getOperation = $pathItem->getGet();
            if ($getOperation === null) {
                continue;
            }

            // Add example to 200 response
            $responses = $getOperation->getResponses();
            $okResponse = $responses['200'] ?? null;
            if ($okResponse instanceof Model\Response) {
                $content = $okResponse->getContent();
                $jsonContent = $content['application/json'] ?? null;
                if ($jsonContent instanceof Model\MediaType) {
                    $updatedContent = $jsonContent->withExample(
                        new \ArrayObject([
                            'id' => 42,
                            'email' => 'user@example.com',
                            'name' => 'Maria Mustermann',
                            'role' => 'editor',
                            'active' => true,
                            'createdAt' => '2025-01-15T10:30:00Z',
                        ])
                    );
                    // Replace in path item...
                }
            }
        }
    }

    private function addSecuritySchemes(OpenApi $openApi): void
    {
        $components = $openApi->getComponents();
        $securitySchemes = $components->getSecuritySchemes() ?? new \ArrayObject();

        $securitySchemes['bearerAuth'] = new \ArrayObject([
            'type' => 'http',
            'scheme' => 'bearer',
            'bearerFormat' => 'JWT',
            'description' => 'JWT Bearer Token. Obtain via POST /auth/token.',
        ]);

        $openApi->withComponents($components->withSecuritySchemes($securitySchemes));
    }

    private function addGlobalErrorResponses(OpenApi $openApi): void
    {
        // Add 401 and 403 to all protected endpoints
        // Implementation iterates all paths and adds standard error responses
    }
}

4. Error documentation: fully describing 4xx and 5xx

API Platform automatically documents only a few error cases: a generic 400 for validation errors and a 404 when a resource is not found. That is not enough for a production-ready API. Every endpoint needs complete documentation of all possible error codes: 401 for unauthenticated requests, 403 for missing permissions, 409 for conflicts, 422 for validation errors with field details, 429 for rate limiting, and 503 for temporary unavailability.

These error cases are not just important for human documentation, they are also the basis for mock servers and contract tests to cover all error paths. In the OpenApiFactory decorator, global error responses are defined once and assigned to all protected endpoints. The components/responses object in the spec holds references to reusable response definitions, which are referenced in every endpoint via $ref: '#/components/responses/Unauthorized'.

5. Security definitions: configuring Bearer and OAuth2 correctly

API Platform's security definitions are minimal: if the JWT bundle is configured, a JWT security scheme appears in the spec, but without bearerFormat, without a description, and without any indication of how to obtain the token. For a complete security context the spec needs one or more security schemes with all parameters, an endpoint for token generation (POST /auth/token), and clear statements about which operations require which security scheme.

OAuth2 flows are not generated automatically. Anyone using OAuth2 with Authorization Code Flow, Client Credentials, or Password Grant must manually add the complete oauth2 security scheme in the OpenApiFactory decorator. That includes authorizationUrl, tokenUrl, refreshUrl, and all scopes with descriptions. Swagger UI and Redoc render this information as complete OAuth2 flow documentation with an interactive authentication option.


# config/packages/api_platform.yaml
api_platform:
  title: 'Mironsoft API'
  version: '2.0.0'
  description: |
    REST API for Mironsoft platform services.

    ## Authentication
    All endpoints require a valid Bearer token obtained via `POST /auth/token`.

    ## Rate Limiting
    Requests are limited to 1000/hour per API key. See `X-RateLimit-*` headers.

  openapi:
    # Additional security schemes added via OpenApiFactory decorator
    api_keys:
      # Empty here, configured programmatically for full control

  formats:
    json: ['application/json']
    jsonld: ['application/ld+json']

  # Disable Hydra context for pure JSON APIs
  defaults:
    formats: ['json']
    input_formats: ['json']

# services.yaml
services:
  App\OpenApi\OpenApiFactory:
    decorates: 'api_platform.openapi.factory'
    arguments:
      - '@.inner'
    tags: []

6. OpenApiFactory: full control over the spec

The OpenApiFactory decorator is the central tool for all manual adjustments to the automatically generated spec. It implements OpenApiFactoryInterface, delegates the actual generation to the decorated factory, and then intervenes selectively in the result. The pattern is precise: let it generate automatically, then override only what is necessary. That minimizes maintenance effort, because new endpoints appear automatically in the spec, and only the specific additions remain manual.

Typical tasks for the decorator: normalizing schema names, adding examples to responses, setting security schemes and global security requirements, registering reusable error responses in components/responses, and marking deprecated endpoints. The decorator can also read external YAML files and merge them with the generated spec, which makes it possible to maintain complex schemas or examples separately and integrate them automatically.

7. Serializer groups and their impact on schemas

Symfony serializer groups determine which fields get serialized in which context. API Platform picks up these groups and generates a separate schema for every combination of endpoint and serializer group. The result: instead of a single User schema there is User.jsonld-user.read, User-user.write, User.jsonld-user.admin and so on. Frontend teams generating TypeScript types end up with a hard-to-navigate zoo of types.

The recommendation: limit serializer groups sensibly. For most APIs, two groups per resource are enough: one read group (user:read) and one write group (user:write). If admin endpoints show different fields than regular user endpoints, a separate ApiResource class for the admin context is cleaner than a third serializer group. That reduces the number of generated schemas and makes the spec navigable.

8. Automatic vs. manual: a direct comparison

This overview shows where API Platform's automatic generation is sufficient and where manual intervention is necessary. This line differs in every project: simple CRUD APIs need less manual work than complex, multi-step authenticated APIs with different client roles.

Feature Automatic Manual work needed Tool
Endpoint generation Fully from attributes - #[ApiResource]
Schema names Internal counters, unreadable shortName + decorator OpenApiFactory
Response examples Completely missing Mandatory in decorator OpenApiFactory
Validation errors Documented generically Add error schema #[ApiProperty]
Security schemes Minimal, no detail bearerFormat, OAuth2 OpenApiFactory

The rule of thumb: anything that can be expressed structurally through PHP classes (fields, types, validation) is generated well by API Platform. Anything that goes beyond structure (semantics, examples, security flows, error descriptions) needs manual intervention through the decorator. The pattern is consistent: code structure equals automatic, documentation quality equals manual.

Mironsoft

API Platform, Symfony and OpenAPI consulting

Want to optimize your API Platform OpenAPI documentation?

We analyze your API Platform configuration, implement an OpenApiFactory decorator, and bring the generated spec up to production quality, with examples, security and complete error documentation.

Spec audit

Analysis of the current generated OpenAPI spec for quality issues

Decorator implementation

OpenApiFactory with examples, security and error responses

Code generator setup

TypeScript client generation from the optimized spec

9. Summary

API Platform generates OpenAPI automatically, and that is the biggest advantage over manual specifications. Endpoint structure, request body schemas and required fields always stay in sync with the PHP code. The limits of automation are clear: schema names need explicit configuration, examples are completely missing, error documentation is rudimentary, and security schemes are incomplete.

The OpenApiFactory decorator is the clean solution for all manual additions. It intervenes after the automatic generation without replacing the existing logic. New endpoints continue to appear automatically in the spec and receive the necessary examples and error responses through the decorator. With shortName on #[ApiResource] and deliberately limited serializer groups, a navigable spec emerges that serves as the foundation for mock servers, contract tests, and TypeScript code generation.

API Platform OpenAPI, the essentials at a glance

Automatically good

Endpoint generation, request body schemas, required fields, content type. Always in sync with PHP code.

Manual work needed

Schema names (shortName), response examples, 4xx/5xx error documentation, complete security schemes.

OpenApiFactory decorator

Implements OpenApiFactoryInterface, delegates to the decorated service, overrides selectively. Declare as a Symfony service.

Serializer groups

Two groups per resource (read/write) as a maximum. More groups means more schemas means an unreadable spec.

10. FAQ: API Platform and OpenAPI

1What does API Platform generate automatically?
Endpoint URLs, HTTP methods, request body schemas from PHP properties, required-field markers, and content type headers. Everything derivable structurally from PHP classes.
2What needs to be added manually?
Schema names, response examples, complete error documentation (401, 403, 429), security schemes with bearerFormat and OAuth2 flows.
3What is the OpenApiFactory decorator?
A Symfony service that implements OpenApiFactoryInterface and decorates api_platform.openapi.factory. Delegates generation, then overrides selectively afterward.
4Clean up schema names like User.jsonld-user.read?
shortName: 'User' in #[ApiResource]. Minimize serializer groups. Rename schema names by convention in the decorator.
5Add response examples?
Access the operation in the OpenApiFactory decorator and call withExample() with a concrete object. Alternatively, for individual fields: #[ApiProperty(openapiContext: ['example' => 'value'])].
6Configure JWT Bearer security?
In the decorator: add a bearerAuth scheme with type: http, scheme: bearer, bearerFormat: JWT to the security schemes. Set security: [{bearerAuth: []}] on protected operations.
7Why are examples missing automatically?
Example data requires real database queries, API Platform cannot automate that. Spectral can flag missing examples as a lint error.
8How many serializer groups are recommended?
A maximum of two per resource: read and write. Every additional group creates a schema. For admin context, a separate ApiResource class is better.
9Merge external YAML files into the spec?
Read them in the decorator with symfony/yaml and merge parts of them into the generated OpenApi instance. Makes it possible to maintain complex schemas and examples separately.
10Use Spectral on an API Platform generated spec?
bin/console api:openapi:export --output=api/openapi.json, then npx spectral lint api/openapi.json --ruleset .spectral.yaml. Integrate into the CI pipeline.