Keeping OpenAPI Examples and Schemas Consistent in Symfony
AI generated
{ }
GET
OpenAPI · Symfony · Nelmio · Schema design
Keeping OpenAPI Examples and Schemas Consistent in Symfony
without drift between docs and implementation

Documentation that diverges from the code is worse than no documentation at all. Anyone who doesn't maintain OpenAPI schemas, validation rules, and example values in a structured way in Symfony builds up technical debt that discharges with every breaking change. This article shows how to keep schemas, examples, and validation permanently in sync.

15 min read Nelmio API Doc · PHP attributes · schema inheritance · contract tests Symfony 6.x · 7.x · OpenAPI 3.1

1. The drift problem: when docs and code diverge

Schema drift is the most common problem in API projects that grow over several months or across teams. One developer adds a new required field to an endpoint, updates the Symfony Validator, but forgets the OpenAPI schema. Another developer relies on the documentation, builds a client that doesn't send the new field, and only gets a validation error at runtime. The cause is structural: when schemas and code live in different artifacts, they inevitably diverge under development pressure.

The solution lies not in discipline but in tools that make divergence impossible, or at least immediately visible. In Symfony there are three levers for this. First, define schemas directly at the PHP code, so a code change automatically changes the documentation along with it. Second, derive validation constraints from the same sources as the schema definitions. Third, automated contract tests that check on every push whether the actual API response matches the declared schema. This article walks through all three approaches with concrete Symfony examples.

2. Nelmio API Doc as the foundation in Symfony

The bundle nelmio/api-doc-bundle is the standard for OpenAPI documentation in Symfony projects. It reads PHP attributes, Doctrine entities, Symfony forms, and PHPDoc annotations and generates a complete openapi.json from them at runtime. The crucial point: the generated specification is never older than the code, because it is recalculated on every request, there is no separate file that would need to be kept up to date manually.

Installation and basic configuration are minimal. After composer require nelmio/api-doc-bundle, you define in config/packages/nelmio_api_doc.yaml which route prefixes should be documented, which authentication mechanisms exist, and which global schema definitions are included. The route /api/doc.json delivers the machine-readable specification, /api/doc the Swagger UI. For CI pipelines, you export the specification once via bin/console nelmio:apidoc:dump --format=json > openapi.json and version the result alongside the code.


# Install Nelmio API Doc Bundle
composer require nelmio/api-doc-bundle

# config/packages/nelmio_api_doc.yaml
nelmio_api_doc:
  documentation:
    info:
      title: "Mironsoft API"
      description: "REST API for Mironsoft services"
      version: "1.0.0"
    servers:
      - url: "https://api.mironsoft.de/v1"
        description: "Production"
      - url: "http://localhost:8080/v1"
        description: "Development"
    components:
      securitySchemes:
        bearerAuth:
          type: http
          scheme: bearer
          bearerFormat: JWT
    security:
      - bearerAuth: []
  areas:
    path_patterns:
      - ^/api(?!/doc$)

# Export spec for CI
bin/console nelmio:apidoc:dump --format=json > openapi.json

A frequently overlooked feature of Nelmio is the area mechanism. Via areas you can generate several separate API documentations, a public one for external consumers and an internal one for admin endpoints. Each area has its own authentication schemes, its own server URLs, and can be reachable via separate routes. This prevents internal endpoints with sensitive parameters from showing up in the public documentation.

3. PHP attributes instead of YAML: schemas right at the code

The OpenApi\Attributes classes from the zircote/swagger-php package make it possible to write OpenAPI schemas directly as PHP attributes on controller methods, DTOs, and model classes. Nelmio reads these attributes automatically. The decisive advantage over separate YAML files: when a developer renames the method, adds parameters, or changes the response type, they see the associated documentation right next to it, and the likelihood increases that they update it too.

The attribute system supports the full scope of OpenAPI 3.1: #[OA\RequestBody], #[OA\Response], #[OA\Parameter], #[OA\Property] for schema fields, #[OA\Schema] for reusable definitions. In DTOs you annotate every property directly, which keeps type, format, description, and example values together with the PHP type declaration. If the PHP type is changed from string to int, the contradiction with #[OA\Property(type: "string")] stands out immediately.


// src/Dto/ProductCreateDto.php
<?php
declare(strict_types=1);

namespace App\Dto;

use OpenApi\Attributes as OA;
use Symfony\Component\Validator\Constraints as Assert;

#[OA\Schema(
    schema: "ProductCreateRequest",
    required: ["name", "price", "categoryId"],
    description: "Payload to create a new product"
)]
final class ProductCreateDto
{
    public function __construct(
        #[OA\Property(description: "Product name", example: "Wireless Headphones", maxLength: 255)]
        #[Assert\NotBlank]
        #[Assert\Length(max: 255)]
        public readonly string $name,

        #[OA\Property(description: "Price in Euro cents", example: 4999, minimum: 1)]
        #[Assert\Positive]
        public readonly int $price,

        #[OA\Property(description: "UUID of the parent category", format: "uuid")]
        #[Assert\Uuid]
        public readonly string $categoryId,

        #[OA\Property(description: "Optional product description", nullable: true)]
        #[Assert\Length(max: 2000)]
        public readonly ?string $description = null,
    ) {}
}

4. Schema inheritance: allOf, oneOf, and discriminator

Real-world APIs rarely have flat, homogeneous schemas. Orders can have different payment methods, notifications can be email, SMS, or push, products can have physical or digital variants. OpenAPI 3.x offers allOf, oneOf, and anyOf for this, with an optional discriminator. In Symfony projects, this polymorphism is modeled with abstract base DTOs and concrete derivations, each carrying its own #[OA\Schema] attribute.

The discriminator tells OpenAPI clients which field is used to distinguish the concrete subtype. The base class's schema declares the discriminator field as required, and the child classes extend it via allOf. Nelmio can derive this hierarchy automatically if the inheritance is expressed through PHP interfaces or abstract classes and the attributes are set correctly. Without a discriminator, code generators have to guess which subtype to instantiate, which leads to hard-to-debug errors in generated clients.

5. Using reusable examples and example fields correctly

OpenAPI distinguishes between example (a single value directly on the schema or parameter) and examples (a named map of example objects in the components section). The examples map is intended for situations where an endpoint has several representative scenarios: a success case, a validation error, an authorization error. Each example has a summary text and a value. Swagger UI and Redoc display these examples as selectable variants, which makes the documentation considerably more useful for API consumers.

In Symfony projects, frequently used examples are often moved out into the components section and referenced with $ref: '#/components/examples/ProductCreated'. This prevents duplicates and ensures that a changed example value gets updated everywhere. It's particularly important that example values match the schema. An example that omits a required field or uses the wrong type gets flagged as an error by some validation tools. The combination of schema validation and example consistency is a frequently underestimated quality indicator for API documentation.


# openapi/examples.yaml: Reusable examples in components section
components:
  examples:
    ProductCreated:
      summary: "Successful product creation"
      value:
        id: "01906c3f-a8b2-7e4d-9f1a-3c2d4e5f6a7b"
        name: "Wireless Headphones"
        price: 4999
        categoryId: "01906c3f-1111-7e4d-9f1a-aabbccddeeff"
        description: "Over-ear with active noise cancellation"
        createdAt: "2026-05-09T14:30:00Z"

    ValidationError:
      summary: "Validation failed, missing required field"
      value:
        type: "https://mironsoft.de/errors/validation"
        title: "Validation Failed"
        status: 422
        violations:
          - field: "name"
            message: "This value should not be blank."
          - field: "price"
            message: "This value should be positive."

  schemas:
    ProblemDetail:
      type: object
      required: [type, title, status]
      properties:
        type:
          type: string
          format: uri
        title:
          type: string
        status:
          type: integer
        violations:
          type: array
          items:
            $ref: '#/components/schemas/ConstraintViolation'

6. Keeping Symfony Validator and OpenAPI constraints in sync

The most common drift point in Symfony APIs sits between validation constraints and OpenAPI schema properties. A field gets marked as #[Assert\Length(max: 255)] in the Symfony Validator, but declared without maxLength: 255 in the OpenAPI schema. Clients that read the documentation have no way of knowing that a length limit exists, until they get a 422 error when sending long values. That's poor API design, and discipline alone cannot permanently prevent it.

The cleanest solution is a shared data source: the DTO properties carry both Symfony validation attributes and OpenAPI property attributes containing the same constraint values. A simple PHPUnit extension can automatically check whether every #[Assert\Length(max: X)] constraint has a corresponding maxLength: X in the associated OA property attribute. The same applies to #[Assert\Range] and minimum/maximum, to #[Assert\NotBlank] and the required array in the schema, and to enum constraints and the enum value in the schema.

7. Components section: managing schemas centrally

The components/schemas section of an OpenAPI specification is the equivalent of a type system. Schemas used in several places, pagination wrappers, error responses, timestamps, money objects, belong in the components section and are referenced via $ref, never duplicated inline. An inline-duplicated schema that appears in ten places has to be updated in ten places on every change, and in practice it won't be, because the developer forgets three of them.

In Symfony, you use dedicated schema classes for this, which serve exclusively as OpenAPI type definitions and are never instantiated, pure documentation objects. Alternatively, you annotate existing value objects and DTOs with #[OA\Schema(schema: "MoneyValue")]. Nelmio collects all classes marked this way and inserts them into the components section automatically. Referencing then happens through type declaration in other schemas: Nelmio recognizes that a property has the type MoneyValue and generates the corresponding $ref.

8. Contract tests: automatically checking schemas against the implementation

The last line of defense against schema drift is automated contract tests. The package league/openapi-psr7-validator or spectator makes it possible to write PHPUnit tests that send a real HTTP request against the Symfony application and automatically validate the response against the declared OpenAPI schema. If a response omits a required field, returns the wrong type, or contains an undeclared property, the test fails, without the test author having to write an assertion themselves.

In practice, a contract test looks like a normal Symfony WebTestCase: boot the kernel, send a request, check the response status. The only difference is that the response body is additionally run through the schema validator. The package loads the generated openapi.json from the project for this, finds the matching path and method, and validates body, headers, and status code. This approach catches regression bugs before they reach production, automatically, without anyone having to read the documentation manually.


// tests/Api/ProductApiContractTest.php
<?php
declare(strict_types=1);

namespace App\Tests\Api;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use League\OpenAPIValidation\PSR7\ValidatorBuilder;

final class ProductApiContractTest extends WebTestCase
{
    private static $validator;

    public static function setUpBeforeClass(): void
    {
        // Load the exported OpenAPI spec
        $specPath = __DIR__ . '/../../openapi.json';
        self::$validator = (new ValidatorBuilder())
            ->fromJsonFile($specPath)
            ->getResponseValidator();
    }

    public function testCreateProductResponseMatchesSchema(): void
    {
        $client = static::createClient();
        $client->request('POST', '/api/products', [], [], [
            'CONTENT_TYPE' => 'application/json',
            'HTTP_AUTHORIZATION' => 'Bearer ' . $this->getTestToken(),
        ], json_encode([
            'name' => 'Wireless Headphones',
            'price' => 4999,
            'categoryId' => '01906c3f-1111-7e4d-9f1a-aabbccddeeff',
        ]));

        $response = $client->getResponse();
        $this->assertSame(201, $response->getStatusCode());

        // Validate response body against OpenAPI schema, fails if schema drifts
        $psr7Response = $this->convertToPsr7($response);
        $operation = new \League\OpenAPIValidation\PSR7\OperationAddress('/api/products', 'post');
        self::$validator->validate($operation, $psr7Response); // throws on mismatch
    }
}

9. Approaches compared side by side

There are several strategies for maintaining OpenAPI schemas in Symfony. The choice has a direct impact on how easily schema drift arises and how quickly it gets detected.

Approach Drift risk Maintenance effort Recommendation
Manual YAML file Very high High (every change twice) Only for very small APIs
PHP attributes + Nelmio Medium Low (next to the code) Recommended as a baseline
Attributes + contract tests Low Low + CI safety net Recommended for teams
API Platform (auto-gen) Very low Very low If API Platform fits
Codegen from YAML-first Low (other direction) Medium (YAML as source of truth) For API-first teams

The combination of PHP attributes directly on DTOs and automated contract tests is the pragmatic sweet spot for most Symfony teams. It requires no additional infrastructure, is readable for every PHP developer, and catches regression bugs automatically. API Platform is the better choice when the API is strongly tied to Doctrine entities and CRUD endpoints dominate.

Mironsoft

REST API design, OpenAPI documentation, and Symfony development

OpenAPI schemas that never drift from the code?

We set up Nelmio API Doc, PHP attributes, and contract tests in your Symfony project and make sure your OpenAPI specification permanently matches the actual API behavior, checked automatically on every CI run.

Schema audit

Reviewing an existing OpenAPI specification for drift, missing examples, and inconsistencies

Nelmio setup

Setting up PHP attributes, components section, and area configuration for your project

Contract tests

Integrating automated schema validation into PHPUnit and the CI pipeline

10. Summary

Consistent OpenAPI documentation in Symfony is not an accident, it's the result of structural decisions. PHP attributes directly on DTOs and controllers keep schema definition and implementation close together, not in separate files that drift apart under development pressure. Nelmio API Doc generates the specification from the code, not the other way around, and thereby eliminates manual synchronization as a source of error. The components section centralizes reusable schemas and prevents duplicates.

Contract tests are the decisive last line of defense: they automatically check whether actual API responses match the declared schema. No developer has to manually check anymore whether a schema change affects all endpoints, the CI run takes care of that. Combined, these four tools form a closed loop: code changes, documentation is updated automatically, contract tests verify the consistency, CI blocks the merge on drift.

Keeping OpenAPI Consistent in Symfony, The Essentials at a Glance

Nelmio API Doc

Generates OpenAPI from PHP code, no manually maintained YAML file, no outdated documentation. Export via console command for CI.

PHP attributes on the DTO

Schema definition, validation constraints, and type declaration together in one place, drift becomes structurally harder.

Components section

Manage reusable schemas, examples, and responses centrally and reference them via $ref, never duplicate inline.

Contract tests

Automatic schema validation of real API responses in PHPUnit, catches regression bugs before they reach production.

11. FAQ: OpenAPI Schemas and Examples in Symfony

1What is schema drift in OpenAPI?
Schema drift: the OpenAPI specification deviates from the actual API behavior. A field exists in the code but not in the schema, or the other way around. Clients built based on the documentation get errors at runtime.
2Why Nelmio instead of manual YAML?
Nelmio generates the specification from the PHP code. Manual YAML files have to be manually updated on every code change, under pressure this regularly doesn't happen.
3Keeping Symfony validation and schema in sync?
Set Assert and OA attributes directly side by side on the same DTO properties. Automated tests check whether constraint values match.
4example vs. examples in OpenAPI?
example is a single inline value. examples is a named map of reusable example objects in the components section, shown as selectable variants in Swagger UI.
5allOf vs. oneOf, when to use which?
allOf: object satisfies all schemas, classic inheritance. oneOf: object satisfies exactly one, for polymorphic types with mutually exclusive variants.
6What are contract tests?
Tests that send real HTTP requests and automatically validate the response against the OpenAPI schema. They catch schema drift without manual assertions.
7Exporting the OpenAPI spec for CI?
bin/console nelmio:apidoc:dump --format=json > openapi.json, version the file, compare it in CI with the newly generated version, warn on unexpected changes.
8Schemas in components instead of inline?
Inline-duplicated schemas have to be updated in every location. $ref references to components schemas take effect everywhere, one change, consistent documentation.
9Multiple API areas with Nelmio?
Yes, via the area mechanism. Each area has its own path_patterns, security schemes, and Swagger UI route. Public and internal APIs cleanly separated.
10What does the discriminator do?
Tells clients which field determines the concrete subtype. Code generators use it for correct deserialization logic with polymorphic schemas.