Symfony 7 + OpenAPI Contract Testing: The Schema-First Workflow That Eliminates 'Works in Swagger, Breaks in Prod'

#Symfony OpenAPI contract testing
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

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.