Checklist: Contracts, Tests, Errors, Security, Tooling
An OpenAPI specification that exists only for documentation does not get you very far. Anyone who treats it as a binding contract between frontend, backend and external consumers, and hardens it with contract tests, RFC 7807 error formats, security schemas and automated tooling, turns a YAML file into a genuinely productive quality tool.
Table of Contents
- 1. Why OpenAPI is more than documentation
- 2. API contracts: design-first vs. code-first
- 3. Contract tests with PHPUnit and the Symfony HttpClient
- 4. Error formats: applying RFC 7807 consistently
- 5. Modeling security schemas correctly in OpenAPI
- 6. Tooling: Spectral, Prism, Stoplight and CI integration
- 7. Versioning strategy and backward compatibility
- 8. Comparison: common mistakes vs. best practices
- 9. Summary
- 10. FAQ
1. Why OpenAPI is more than documentation
In many projects OpenAPI is used purely as a documentation tool: the specification is generated from annotations in the code, produced automatically, and displayed in a Swagger UI. That solves the problem of missing documentation, but it misses the actual potential. When applied consistently, an OpenAPI specification is a binding contract between everyone involved: frontend teams know exactly which fields and status codes they can expect. Backend teams get automatic feedback through linting whenever they violate the contract. External consumers can generate clients without documenting anything manually.
The difference between a documentation YAML and a production-ready contract comes down to three points: first, completeness of error responses, second, machine-readable security schemas, and third, automated checks that ensure the code actually matches the contract. Anyone who systematically addresses these three points turns OpenAPI from a static artifact into a living quality tool. The following checklist covers all the relevant areas, from contract definition through tests and error formats to security schemas and CI integration.
2. API contracts: design-first vs. code-first
The fundamental decision when integrating OpenAPI into Symfony is whether the specification is generated from the code (code-first) or whether the YAML is written first and the code is then built underneath it (design-first). In practice, design-first has decisive advantages: frontend and backend teams can develop in parallel as soon as the contract is settled. The specification can be reviewed by reviewers and consumers before a single line of implementation exists. Breaking changes are visible early, before they are cast in code.
In a Symfony context, design-first means the openapi.yaml lives in config/api/, is versioned, and is the single source of truth. NelmioApiDocBundle is then only used to render the Swagger UI, not for generation. The code has to fulfill the contract, not the other way around. Contract tests mechanically ensure that every response matches the declared schema. This creates a loop: define the contract, implement the code, tests confirm conformance, CI blocks on deviations.
# config/api/openapi.yaml, Design-First: Specification is the source of truth
openapi: "3.1.0"
info:
title: "Mironsoft Shop API"
version: "2.0.0"
contact:
name: "Mironsoft Engineering"
email: "api@mironsoft.de"
servers:
- url: "https://api.mironsoft.de/v2"
description: "Production"
- url: "https://api-staging.mironsoft.de/v2"
description: "Staging"
paths:
/products/{id}:
get:
operationId: getProduct
summary: "Retrieve a single product"
tags: [Products]
parameters:
- name: id
in: path
required: true
schema:
type: integer
minimum: 1
responses:
"200":
description: "Product found"
content:
application/json:
schema:
$ref: "#/components/schemas/Product"
"404":
$ref: "#/components/responses/NotFound"
"401":
$ref: "#/components/responses/Unauthorized"
3. Contract tests with PHPUnit and the Symfony HttpClient
Contract tests check mechanically whether the actual API responses match the declared OpenAPI schema. Without these tests, the specification can drift away from the code, and nobody notices until a consumer complains. The tool of choice in Symfony is the HttpClient from the Symfony framework combined with a JSON schema validation library such as justinrainbow/json-schema. The flow: send the request, receive the response, extract the corresponding schema path from the OpenAPI file, and validate the response against it.
A complete contract test does not just cover the happy path, it covers every declared error case as well. A 404 response must match the ProblemDetails schema. A 422 response must contain an errors array with field information. Tests that only check 200 responses give a false sense of security, in practice error responses are the most common source of contract violations. PHPUnit data providers make it possible to systematically walk through every declared path and status code.
validator = new Validator(new \JsonSchema\Constraints\Factory($schemaStorage));
$raw = file_get_contents(__DIR__ . '/../../../config/api/openapi.yaml');
$this->schema = \Symfony\Component\Yaml\Yaml::parse($raw);
}
public function testGetProductReturns200WithValidSchema(): void
{
$client = static::createClient();
$client->request('GET', '/api/v2/products/1', [], [], [
'HTTP_AUTHORIZATION' => 'Bearer ' . $this->getTestToken(),
'HTTP_ACCEPT' => 'application/json',
]);
$response = $client->getResponse();
self::assertSame(200, $response->getStatusCode());
$body = json_decode($response->getContent(), false);
$productSchema = $this->schema['components']['schemas']['Product'];
$this->validator->validate($body, (object) $productSchema);
self::assertTrue(
$this->validator->isValid(),
'Response does not match OpenAPI schema: '
. json_encode($this->validator->getErrors())
);
}
public function testGetNonExistentProductReturns404ProblemDetails(): void
{
$client = static::createClient();
$client->request('GET', '/api/v2/products/99999', [], [], [
'HTTP_AUTHORIZATION' => 'Bearer ' . $this->getTestToken(),
]);
$response = $client->getResponse();
self::assertSame(404, $response->getStatusCode());
self::assertSame('application/problem+json', $response->headers->get('Content-Type'));
$body = json_decode($response->getContent(), true);
self::assertArrayHasKey('type', $body);
self::assertArrayHasKey('title', $body);
self::assertArrayHasKey('status', $body);
self::assertSame(404, $body['status']);
}
private function getTestToken(): string
{
// Returns a pre-generated test JWT for integration tests
return $_ENV['TEST_API_TOKEN'] ?? 'test-token';
}
}
4. Error formats: applying RFC 7807 consistently
RFC 7807 (Problem Details for HTTP APIs) defines a standardized JSON format for error messages in REST APIs. The fields type (a URI), title (human readable), status (the HTTP status code), detail (context-specific) and instance (the URI of the concrete request) enable consistent error handling in the client. Symfony has had native support for problem details since version 6.1 via the ApiPlatform-compatible ErrorController. Anyone not using Api Platform implements their own ExceptionSubscriber that transforms all exceptions into RFC 7807-compliant responses.
The crucial detail is that the content type must be application/problem+json, not application/json. Clients can use this header to automatically distinguish normal responses from error responses. In the OpenAPI schema, every error response references the ProblemDetails schema, and the contract test checks the content type mechanically. Validation errors (422) extend the schema with a violations array that contains a path, constraint name and message for each field.
5. Modeling security schemas correctly in OpenAPI
Security schemas in OpenAPI are frequently incomplete or modeled incorrectly, which causes generated clients to implement authentication wrong. OpenAPI 3.1 supports four security scheme types: apiKey (header, query or cookie), http (Basic, Bearer), oauth2 (with a full flow model) and openIdConnect. Every endpoint must be explicitly given the matching security requirement, global security requirements do apply to all endpoints, but exceptions (public endpoints) must be explicitly declared with security: [].
In a Symfony context this means the security.yaml firewall must exactly mirror what the OpenAPI schema declares. A common discrepancy: the schema marks an endpoint as public, but the firewall protects it with a voter, or the other way around. Contract tests that call the endpoint without a token expose this discrepancy immediately. For OAuth2 flows, the authorizationUrl, tokenUrl and scopes should be fully specified in the schema, because code generators use this information for fully automatic token acquisition.
# Security schemas in OpenAPI 3.1, complete and correct
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: |
JWT issued by POST /auth/token. Include as:
Authorization: Bearer
ApiKeyAuth:
type: apiKey
in: header
name: X-API-Key
description: "Static API key for server-to-server communication"
OAuth2:
type: oauth2
flows:
authorizationCode:
authorizationUrl: "https://auth.mironsoft.de/oauth/authorize"
tokenUrl: "https://auth.mironsoft.de/oauth/token"
refreshUrl: "https://auth.mironsoft.de/oauth/refresh"
scopes:
"products:read": "Read product catalog"
"products:write": "Create and update products"
"orders:read": "Read own orders"
"orders:write": "Place and cancel orders"
# Apply globally, override per endpoint for public routes
security:
- BearerAuth: []
paths:
/health:
get:
security: [] # Explicitly public, no token required
summary: "Health check endpoint"
6. Tooling: Spectral, Prism, Stoplight and CI integration
Without automated tooling, an OpenAPI specification is hard to keep consistent. Spectral is a rule-based linter for OpenAPI documents that ensures style guide rules (naming conventions, required fields, error format requirements) are followed. Spectral rules are defined as YAML and can encode project-specific requirements: for example that every POST endpoint declares a 201 response with a Location header, or that all error responses reference the ProblemDetails schema.
Prism is a mock server that runs directly from the OpenAPI specification and returns real HTTP responses based on the declared examples. Frontend teams can develop against the mock server as soon as the contract is settled, without waiting for a backend implementation. Prism also validates incoming requests against the schema and returns meaningful error messages when a request does not conform. In the CI pipeline, Spectral runs before every merge, and Prism validation runs as part of the contract tests.
# .spectral.yaml, Custom API linting rules for the project
extends:
- "spectral:oas"
rules:
# Every POST must declare 201 or 202
post-must-return-201-or-202:
message: "POST endpoints must declare 201 (Created) or 202 (Accepted) response"
severity: error
given: "$.paths[*].post.responses"
then:
function: schema
functionOptions:
schema:
anyOf:
- required: ["201"]
- required: ["202"]
# All error responses must reference ProblemDetails
error-responses-must-use-problem-details:
message: "Error responses (4xx, 5xx) must reference ProblemDetails schema"
severity: warn
given: "$.paths[*][*].responses[4,5]?(*)"
then:
field: "content.application/problem+json"
function: truthy
# Operation IDs must be camelCase
operation-id-camel-case:
message: "operationId must be camelCase"
severity: warn
given: "$.paths[*][*].operationId"
then:
function: pattern
functionOptions:
match: "^[a-z][a-zA-Z0-9]*$"
7. Versioning strategy and backward compatibility
API versioning is one of the most common points of contention in API design. URL versioning (/v1/, /v2/) is explicit and client friendly, but requires every consumer to migrate on a major update. Header versioning (Accept: application/vnd.mironsoft.v2+json) is more HTTP-conformant, but harder to test and cache. A pragmatic strategy for Symfony is URL versioning for major versions, combined with a strict definition of breaking changes.
A breaking change is: a required field is added, a field is renamed, an enum value is removed, a status code changes, a URL changes. A non-breaking change is: an optional field is added, a new endpoint is added, a new enum value is added. Checking for breaking changes can be automated: openapi-diff or oasdiff compare two OpenAPI documents and flag breaking changes as CI failures. This prevents breaking changes from slipping into minor versions.
8. Comparison: common mistakes vs. best practices
The table below shows the most common mistakes in production OpenAPI projects and the recommended counter-pattern. Many of these mistakes do not come from lack of knowledge, they come from time pressure or missing CI automation that would otherwise catch them.
| Area | Common mistake | Best practice | Impact |
|---|---|---|---|
| Error format | {"error": "Not found"} |
RFC 7807 ProblemDetails | Clients can handle errors programmatically |
| Security | No security schemas declared | Global plus endpoint-specific security | Code generators implement auth correctly |
| Error responses | Only 200 documented, 4xx missing | All declared status codes as a schema | Contract tests also check error paths |
| Versioning | Breaking changes without a major version | oasdiff in CI as a breaking-change guard | Consumers are not broken without warning |
| Linting | YAML maintained manually, no linting | Spectral in the CI pipeline | Style guide violations are caught automatically |
9. Summary
A production-ready OpenAPI specification in Symfony does not come from annotation generation, it comes from a systematic approach: design-first defines the contract, contract tests confirm conformance, RFC 7807 standardizes error formats, complete security schemas enable correct code generation, and Spectral in the CI pipeline keeps quality up automatically. Each of these five elements is valuable on its own, but only in combination do they form a system that automatically prevents breaking changes and guarantees reliable stability for API consumers.
The most important organizational step is to take the OpenAPI specification out of the code review process and give it its own review step, ideally with frontend involvement, before a single line of implementation is written. Design-first structurally enforces this step and turns API design into a collaborative decision instead of a backend-only decision.
Production-Ready OpenAPI in Symfony — The Essentials at a Glance
Design-First
The OpenAPI YAML is the source of truth. Code has to fulfill the contract, not the other way around. NelmioApiDocBundle only for the Swagger UI.
Contract Tests
PHPUnit plus json-schema validates every response against the declared schema. Error paths (4xx) are tested too.
RFC 7807 Error Format
Content-Type: application/problem+json. Fields: type, title, status, detail, instance. Violations array on 422.
CI Tooling
Spectral lint on every PR. oasdiff checks breaking changes. Prism as a mock server for frontend development.