catching breaking changes before deployment
A green end to end test does not prove that every consumer of a Symfony API keeps working once a response field changes. Contract testing makes every consumer's expectations explicit and checks them automatically against the real provider, long before a breaking change causes damage in production.
Table of contents
- 1. Why end to end tests do not reliably catch breaking changes
- 2. Consumer driven contracts: the core principle
- 3. Generating a PACT contract from the consumer side
- 4. Provider verification against the contract in Symfony
- 5. OpenAPI schema validation as a lightweight alternative
- 6. Contract versioning with the Pact Broker
- 7. Integrating contract tests into the CI pipeline
- 8. Common mistakes when introducing contract testing
- 9. Contract testing compared to other API test strategies
- 10. Summary
- 11. FAQ
1. Why end to end tests do not reliably catch breaking changes
Contract testing solves a problem that shows up regularly in microservice landscapes and with multiple API consumers: a team changes a Symfony API, all of its own tests stay green, but an external consumer, such as a mobile app or a partner system, breaks in production because it relied on a detail that was never documented anywhere. End to end tests against your own codebase naturally do not check the behavior of foreign consumers you often do not even know about.
The core of contract testing is making these implicit expectations explicit: every consumer defines a contract that precisely describes which fields it expects from which response and which requests it calls the API with. This contract then gets automatically verified against the real provider, regardless of whether consumer and provider live in the same repository, the same team, or the same organization.
This article shows how to generate a consumer driven contract for a Symfony API with PACT, how provider verification works, how OpenAPI schema validation serves as a lightweight alternative, and how contract tests get integrated into the CI pipeline without slowing down deployments.
2. Consumer driven contracts: the core principle
In a consumer driven contract, it is not the provider team that writes down what the API returns, but each consumer team that describes what it actually needs. This inversion matters: a provider can add, rename, or remove fields that no consumer uses, without any contract test failing. Only changes that affect actually used fields trigger a red test, which drastically reduces false positives compared to a full schema validation that treats every deviation as an error.
In practice this means, for a Symfony API with multiple consumers: a mobile app defines its own contract with the fields its UI actually renders. A partner system defines a separate contract with the fields its integration needs. Both contracts get verified independently against the same provider endpoint, letting the provider team see exactly which change would affect which consumer, before anything even gets deployed.
3. Generating a PACT contract from the consumer side
PACT is the most common framework for consumer driven contract testing and ships with a PHP implementation that can be integrated into Symfony consumer tests. The consumer test simulates the interaction with the API against a mock server started by PACT, defines the expected request and response, and PACT generates a contract file in JSON format from that, precisely documenting what the consumer expects.
It matters that the consumer test runs against the PACT mock server, not against the real Symfony API. That keeps consumer tests fast and independent from the provider's deployment. The generated contract file then gets handed over to the provider team, usually through a Pact Broker, which verifies the contracts of all consumers against its actual implementation.
<?php
declare(strict_types=1);
namespace App\Tests\Contract\Consumer;
use PhpPact\Consumer\Model\ConsumerRequest;
use PhpPact\Consumer\Model\ProviderResponse;
use PhpPact\Standalone\MockService\MockServerEnvConfig;
use PhpPact\Standalone\MockService\MockServerHttpClientConfig;
use PHPUnit\Framework\TestCase;
final class ProductApiConsumerContractTest extends TestCase
{
public function testMobileAppExpectsProductNameAndPrice(): void
{
$request = new ConsumerRequest();
$request->setMethod('GET')
->setPath('/api/products/42')
->addHeader('Accept', 'application/json');
$response = new ProviderResponse();
$response->setStatus(200)
->addHeader('Content-Type', 'application/json')
->setBody(['id' => 42, 'name' => 'Test Product', 'price' => 19.99]);
$config = new MockServerEnvConfig();
$builder = new \PhpPact\Consumer\InteractionBuilder($config);
$builder->uponReceiving('a request for a product used in the mobile app')
->with($request)
->willRespondWith($response);
$builder->run(function () use ($config) {
$client = new \GuzzleHttp\Client(['base_uri' => $config->getBaseUri()]);
$response = $client->get('/api/products/42', ['headers' => ['Accept' => 'application/json']]);
self::assertSame(200, $response->getStatusCode());
});
}
}
4. Provider verification against the contract in Symfony
Provider verification is the second, equally important part of contract testing. The Symfony provider team starts the real application, usually with a test database and fixtures that supply the data expected in the contract, and lets PACT replay every interaction from every consumer contract against this running application. If the actual response deviates from the expectation defined in the contract, verification fails, before the code is even merged.
A decisive advantage of this direction: the provider team sees, in a single CI run, which of the registered consumer contracts would be affected by a planned change. That replaces the tedious manual back and forth of asking every consumer team whether a planned API change might cause problems, with an automated, deterministic check.
<?php
declare(strict_types=1);
namespace App\Tests\Contract\Provider;
use PhpPact\Standalone\ProviderVerifier\Model\VerifierConfig;
use PhpPact\Standalone\ProviderVerifier\Verifier;
use PHPUnit\Framework\TestCase;
final class ProductApiProviderVerificationTest extends TestCase
{
public function testProductApiSatisfiesAllRegisteredContracts(): void
{
$config = (new VerifierConfig())
->setProviderName('product-api')
->setProviderBaseUrl('http://127.0.0.1:8000')
->setPactBrokerBaseUri('https://pact-broker.mironsoft.internal')
->setPublishResults(true)
->setProviderVersion(getenv('CI_COMMIT_SHA') ?: 'local');
$verifier = new Verifier($config);
$verifier->verify();
self::assertTrue(true); // verify() throws on any contract mismatch
}
}
5. OpenAPI schema validation as a lightweight alternative
Not every Symfony project needs the full PACT infrastructure with a broker and separate consumer repositories. If API Platform or NelmioApiDocBundle already generates an OpenAPI specification, this specification can be validated directly against the actual API responses, with libraries like league/openapi-psr7-validator. This is not full consumer driven contract testing, but a pragmatic intermediate step that at least ensures the API adheres to its own documented specification.
The difference from the PACT strategy: OpenAPI validation checks against a central schema defined by the provider, not against the actual expectations of individual consumers. A field marked optional in the schema but strictly required by a particular consumer does not show up with pure schema validation. For projects with a few well known consumers, this lighter strategy is often enough, for many external consumers, real contract testing is the more robust choice.
<?php
declare(strict_types=1);
namespace App\Tests\Functional\OpenApi;
use League\OpenAPIValidation\PSR7\ValidatorBuilder;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
final class ProductEndpointOpenApiComplianceTest extends WebTestCase
{
public function testProductResponseMatchesOpenApiSchema(): void
{
$client = static::createClient();
$client->request('GET', '/api/products/42');
$validator = (new ValidatorBuilder())
->fromYamlFile(__DIR__ . '/../../../public/openapi.yaml')
->getResponseValidator();
$psrResponse = $this->toPsrResponse($client->getResponse());
// Throws on schema mismatch — turns undocumented drift into a failing test.
$validator->validate(
new \League\OpenAPIValidation\PSR7\OperationAddress('/api/products/{id}', 'get'),
$psrResponse
);
self::assertTrue(true);
}
}
6. Contract versioning with the Pact Broker
The Pact Broker is the central hub where consumer contracts get published and provider verification results get stored. Without a broker, contract files would need to be copied manually between repositories, which in practice quickly goes stale and inconsistent. With the broker, every consumer team registers its contract on every build, and the provider team automatically queries the broker for all currently valid contracts, instead of maintaining them fixed inside its own repository.
A particularly useful feature of the Pact Broker is the so called "can-i-deploy" check: before a provider gets deployed, the CI pipeline asks the broker whether all registered consumer contracts have already been successfully verified for the current provider version. If not, the broker blocks the deployment, which keeps a breaking change from reaching production at all, regardless of whether anyone remembered to check every consumer manually.
7. Integrating contract tests into the CI pipeline
Contract tests belong in two separate CI jobs: one for consumer tests, which runs on every build in the consumer repository and publishes the contract to the broker, and one for provider verification, which runs on every build in the provider repository, fetches all registered contracts, and verifies them. This separation lets both teams deploy independently of each other, as long as the "can-i-deploy" check stays green.
A common misunderstanding is treating contract testing as a replacement for end to end tests. Contract tests check the interface between two systems, not the complete business logic behind the interface. A small number of real end to end tests for the most critical user flows remains sensible, complemented by a significantly larger number of fast contract tests that individually secure every consumer provider relationship.
8. Common mistakes when introducing contract testing
A common mistake is letting the provider team introduce contract testing alone, without involving the consumer teams. Since contracts are consumer driven, every consumer team needs the responsibility to maintain its own contract and update it whenever its own usage changes. Without this shared responsibility, contract testing degenerates into another form of schema validation that gives away the real benefit of consumer specific expectations.
<?php
// WRONG: provider team writes contracts on behalf of consumers,
// guessing what fields might be used — defeats the purpose of
// consumer driven contracts and drifts from real usage over time.
// RIGHT: each consumer team owns and maintains its own contract test,
// re-generating the contract whenever its actual API usage changes.
A second mistake is running provider verification without real database fixtures that actually reflect the state expected in the contract. If the provider application starts with empty or wrong test data, verification can fail on a 404 instead of a real contract violation, which dilutes the actual signal of contract testing and leads to ignored, permanently red tests.
9. Contract testing compared to other API test strategies
Contract testing is one of several strategies to secure API compatibility. The following table maps the approaches to their use case.
| Strategy | Checks | Speed | Covers foreign consumers |
|---|---|---|---|
| End to end test | Complete user flow | Slow | No |
| OpenAPI validation | Response against a central schema | Fast | Partially |
| Consumer driven contract (PACT) | Explicit consumer expectations | Fast | Yes |
| Manual cross team coordination | Whatever someone happens to mention | Very slow | Unreliable |
For Symfony APIs with multiple, sometimes external consumers, contract testing with PACT delivers the most reliable protection against breaking changes, while OpenAPI validation remains a good first step for smaller projects with a few well known consumers.
Mironsoft
Symfony APIs, contract testing, and CI pipelines
Finding breaking changes before consumers do?
We introduce consumer driven contract testing with PACT into existing Symfony APIs, set up the Pact Broker along with a "can-i-deploy" gate, and prevent API changes from silently breaking consumers.
Contract rollout
PACT setup for consumer and provider tests in Symfony
Broker operations
Pact Broker deployment and deployment gate configuration
API governance
OpenAPI specifications and schema validation as a complement
10. Summary
Contract testing closes a gap that classic end to end tests structurally cannot close: the confidence that a change to a Symfony API keeps serving every real consumer. With PACT as the consumer driven contract framework, every consumer defines its own expectations, the provider team verifies all registered contracts against the real implementation, and the Pact Broker prevents a breaking change from being deployed at all through the "can-i-deploy" check.
For smaller projects with a few well known consumers, a lighter OpenAPI schema validation is often enough as a first step. For Symfony APIs with multiple teams or external partners, real contract testing is the more robust investment, because it captures exactly the consumer specific expectations that a central schema check systematically misses.
Contract testing for Symfony APIs: the essentials at a glance
Consumer driven
Every consumer defines its own contract, only used fields trigger test failures.
Two separate jobs
Consumer test publishes the contract, provider verification checks it against the real application.
Pact Broker as a gate
"can-i-deploy" check blocks deployments before all contracts are verified.
Complement, not a replacement
A small number of end to end tests still makes sense for critical user flows.