Symfony 7 + OpenAPI Contract Testing: The Schema-First Workflow That Eliminates 'Works in Swagger, Breaks in Prod'
There is a particular kind of production incident that feels especially avoidable in hindsight: the one where Swagger showed a green tick, the Postman collection passed, and the React frontend deployed against a spec that no longer matched the actual Symfony response. Symfony OpenAPI contract testing exists precisely to close that gap — not by writing more unit tests, but by making the spec itself executable and treating any deviation from it as a build failure.
This post walks through the full pipeline we use on API Platform 4 + Symfony 7 projects: generating the OpenAPI spec as a CI artefact, running Schemathesis property-based tests against a live Symfony test kernel, enforcing breaking-change detection with oasdiff on every pull request, and standing up a Prism mock server so frontend teams can develop against a spec-accurate stub before the real endpoint exists. Every piece is wired into GitHub Actions so the feedback loop is automatic.
Why API Platform's Auto-Generated Spec Is Not Enough
API Platform 4 is genuinely good at OpenAPI generation. Point it at a Symfony entity decorated with the right attributes and it produces a 3.1-compliant spec with minimal ceremony. The problem is not the spec at the moment of generation — it is the spec three months later, after a dozen incremental changes to serialisation groups, after a custom state provider was introduced, after someone added a nullable field without updating the DTO.
At that point the spec is still valid YAML, the Swagger UI still renders beautifully, and the CI pipeline shows green — because nobody configured CI to check whether the spec matches what the application actually returns at runtime.
Contract testing is the missing layer. It treats the spec as the source of truth and the running application as the implementation that must conform to it.
Step 1: Export the OpenAPI Spec as a CI Artefact
Before you can test against the spec, you need to export it deterministically from the Symfony application itself — not from an editor, not from a hand-maintained YAML file.
API Platform ships a console command for this:
php bin/console api:openapi:export --output=openapi.json --format=json
In GitHub Actions, add this as the first step of your contract-testing job:
- name: Export OpenAPI spec
run: |
APP_ENV=test php bin/console api:openapi:export \
--output=openapi.json \
--format=json
Run this in APP_ENV=test so the spec reflects the same Symfony configuration that the tests will use. Upload openapi.json as a workflow artefact so other jobs — and your frontend team — can consume it downstream.
Step 2: Schemathesis Property-Based Contract Tests
Schemathesis reads your OpenAPI spec and auto-generates HTTP test cases that probe every endpoint. Unlike writing Behat scenarios by hand, Schemathesis uses property-based testing: it mutates inputs, explores edge cases, and checks that every response matches the schema declared for that status code.
Install it in CI:
pip install schemathesis
Wire it against your Symfony test server. The cleanest approach is to start Symfony's built-in server in the background and point Schemathesis at it:
- name: Start Symfony test server
run: symfony server:start --port=8001 --no-tls &
- name: Run Schemathesis contract tests
run: |
st run openapi.json \
--base-url=http://localhost:8001 \
--checks=all \
--stateful=links \
--junit-xml=schemathesis-results.xml
The four Schemathesis strategies worth enabling explicitly are not_a_server_error (any 5xx response fails), response_schema_conformance (response body must match declared schema), content_type_conformance (Content-Type header must match spec), and status_code_conformance (only status codes declared in the spec are acceptable). Together these four catch roughly 90% of serialisation regressions — missed nullable fields, removed properties, changed enum values, added required fields with no default.
If your API uses JWT authentication, pass a valid test token via --header "Authorization: Bearer ${TEST_TOKEN}". Generate the token in a setUp step using a Symfony command or a fixture that creates a test user.
Step 3: Breaking-Change Detection with oasdiff
Schemathesis checks that the implementation matches the current spec. oasdiff checks that the current spec does not break the previous spec — catching consumer-breaking changes before they merge.
pip install oasdiff
In GitHub Actions, download the spec from the base branch and diff it against the spec generated on the PR branch:
- name: Download base spec
run: |
git fetch origin master
git show origin/master:openapi.json > openapi-base.json || echo '{}' > openapi-base.json
- name: Check for breaking changes
run: |
oasdiff breaking openapi-base.json openapi.json \
--fail-on ERR \
--format text
--fail-on ERR makes the job fail only on breaking changes — removed endpoints, changed required parameters, incompatible type changes — while allowing additive changes through. You can promote warnings to errors incrementally as your team's discipline matures.
This is the step that prevents the "I just removed that deprecated field" incident. The PR cannot merge until either the breaking change is intentional and the version is bumped, or the change is reverted.
Step 4: Prism Mock Server for Frontend-First Development
Once the spec is a trusted, tested artefact, your React or Next.js frontend team can develop against it before the real endpoint exists. Prism reads the OpenAPI spec and responds with schema-valid, example-populated mock responses.
Run Prism in Docker in your GitHub Actions workflow:
- name: Start Prism mock server
run: |
docker run --rm -d \
-p 4010:4010 \
-v ${{ github.workspace }}/openapi.json:/openapi.json \
stoplight/prism:4 mock /openapi.json
- name: Run frontend smoke tests against Prism
run: |
cd frontend
npm ci
NEXT_PUBLIC_API_BASE=http://localhost:4010 npm run test:smoke
The value here is not that Prism is a perfect substitute for the real API — it is not. The value is that any frontend code that compiles and passes smoke tests against Prism is already validated against the spec. When the real endpoint ships, the spec-to-implementation contract tests confirm the implementation is conformant, so the frontend-to-backend integration works without a manual testing round.
Step 5: The Complete GitHub Actions Workflow
Putting it all together as a single job that runs on every PR:
name: Contract Tests
on:
pull_request:
push:
branches: [master]
jobs:
contract:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: app_test
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: pdo_pgsql, intl
- name: Install PHP dependencies
run: composer install --no-interaction --prefer-dist
- name: Run database migrations
run: php bin/console doctrine:migrations:migrate --no-interaction --env=test
- name: Load fixtures
run: php bin/console hautelook:alice:load --no-interaction --env=test
- name: Export OpenAPI spec
run: APP_ENV=test php bin/console api:openapi:export --output=openapi.json
- name: Upload spec artefact
uses: actions/upload-artifact@v4
with:
name: openapi-spec
path: openapi.json
- name: Download base spec for diff
run: |
git fetch origin ${{ github.base_ref || 'master' }}
git show origin/${{ github.base_ref || 'master' }}:openapi.json > openapi-base.json 2>/dev/null || echo '{}' > openapi-base.json
- name: Install Python tools
run: pip install schemathesis oasdiff
- name: Start Symfony test server
run: symfony server:start --port=8001 --no-tls --env=test &
- name: Wait for server
run: sleep 3
- name: Run Schemathesis
run: |
st run openapi.json \
--base-url=http://localhost:8001 \
--checks=all \
--stateful=links \
--junit-xml=schemathesis-results.xml
- name: Check for breaking changes
run: oasdiff breaking openapi-base.json openapi.json --fail-on ERR
- name: Publish test results
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: schemathesis-results.xml
What This Catches That Unit Tests Miss
The workflow above regularly surfaces issues that no amount of PHPUnit coverage would catch:
Serialisation group renames — A Symfony serialisation group was renamed, removing a field from the API response but leaving the PHP method intact. PHPUnit passes, contract test fails on response_schema_conformance within 30 seconds.
Nullable edge cases — A nullable: false field in the spec was returned as null under a specific edge case. Schemathesis's property-based fuzzing found the triggering input combination in under two minutes. A hand-written test would never have guessed that combination.
Endpoint removal — A developer removed a GET /widgets/{id} endpoint that the mobile client still depended on. oasdiff flagged it as a breaking change before the PR could be merged. No incident, no late-night rollback.
New required parameters without defaults — A new required request parameter was added without a default value. Schemathesis's stateful link traversal hit the endpoint through a realistic call sequence and returned a 422 — exactly the response a client would see in production.
None of these failures are visible in Swagger UI. None are caught by a test suite that only exercises the PHP layer in isolation. They are the specific class of bug that lives in the gap between the spec and the implementation — and contract testing is the only tool designed to find that gap automatically.
A Note on Scope
Setting up the spec export and Schemathesis steps is a solid afternoon's work for an engineer who already knows the codebase. The oasdiff and Prism integrations add another day once the basics are running. The harder problem is calibrating what counts as a breaking change for your specific API consumers and tuning oasdiff's severity levels accordingly — that calibration is worth doing carefully once rather than reactively after an incident.
Our code quality consulting and custom software development engagements regularly include API contract testing as part of a broader quality baseline. If you want a second opinion on your current Symfony testing approach, or hands-on help getting this pipeline running in your CI environment, send a note to hello@wolf-tech.io or visit wolf-tech.io to start a conversation.

