Preventing breaking changes between PHP services with PHPUnit
Two independently deployable PHP services communicating over an API can each have a fully green test suite and still become incompatible. Contract tests with PHPUnit close exactly this gap between provider and consumer.
Table of Contents
- 1. Why green unit tests alone are not enough
- 2. The basic principle of consumer driven contracts
- 3. Writing the consumer test with PHPUnit
- 4. Verifying the provider test against the same contract
- 5. Using the Pact Broker as a central contract store
- 6. A lightweight alternative without an extra library
- 7. What contract tests deliberately do not check
- 8. Integrating contract tests into the CI pipeline
- 9. Conclusion: technically securing trust between services
- 10. Summary
- 11. FAQ
1. Why green unit tests alone are not enough
As soon as a system is split into multiple independently deployable services, for example an order service and a separately operated pricing service communicating via a REST or GraphQL API, a substantial part of correctness shifts to the boundary between them. Each service can have a perfectly green PHPUnit suite on its own and still become incompatible the moment someone renames a response field, adds a required parameter, or changes a data format, without the other service ever learning about it.
The actual problem is that classic unit and even integration tests within a single service hard-wire assumptions about the other service's behavior, usually through mocks or stubs. When the real API changes, those mocks stay green regardless, even though the real system no longer works. This silent divergence between mock behavior and real API behavior is exactly the core problem contract tests solve.
2. The basic principle of consumer driven contracts
In consumer driven contract testing, the consumer, the service calling an API, explicitly states its expectations about the response in the form of a machine-readable contract. This contract is then replayed against the provider, the service exposing the API, checking whether the actual response matches those expectations. Both sides test against the same shared contract instead of maintaining assumptions isolated from each other.
The decisive advantage over end-to-end tests, where both services have to be spun up together in a test environment, is that contract tests can verify each service in isolation. The consumer tests against a mock server generated from the contract, the provider tests against the same contract with its real implementation. Both test runs are therefore fast, independently executable, and require no shared running infrastructure.
3. Writing the consumer test with PHPUnit
On the consumer side, a library like pact-foundation/pact-php is used to first formulate the expected interaction: which request the consumer sends and which response structure it expects. PHPUnit takes on its familiar role as test runner, while the Pact library starts a mock server in the background that serves exactly this interaction and simultaneously records a contract as a JSON file.
It is important to check only the fields relevant to your own service in the consumer test, rather than mirroring the provider's entire response structure one to one. Anyone who includes too many fields in the contract couples too tightly to details their own service does not even use, making otherwise harmless future API extensions harder for the provider.
<?php
declare(strict_types=1);
namespace Tests\Contract;
use PhpPact\Consumer\InteractionBuilder;
use PhpPact\Consumer\Model\ConsumerRequest;
use PhpPact\Consumer\Model\ProviderResponse;
use PhpPact\Standalone\MockService\MockServerEnvConfig;
use PHPUnit\Framework\TestCase;
final class PriceServiceConsumerTest extends TestCase
{
public function testFetchesPriceForProduct(): void
{
$config = new MockServerEnvConfig();
$builder = new InteractionBuilder($config);
$request = (new ConsumerRequest())
->setMethod('GET')
->setPath('/api/prices/42');
$response = (new ProviderResponse())
->setStatus(200)
->addHeader('Content-Type', 'application/json')
->setBody(['productId' => 42, 'grossPrice' => 19.99, 'currency' => 'EUR']);
$builder->given('a price exists for product 42')
->uponReceiving('a request for the product price')
->with($request)
->willRespondWith($response);
$builder->run(function () use ($config) {
$client = new PriceServiceClient($config->getBaseUri());
$price = $client->fetchPrice(42);
self::assertSame(19.99, $price->grossPrice());
});
}
}
4. Verifying the provider test against the same contract
On the provider side, the previously recorded contract is replayed against the real, running implementation of the pricing service. Instead of the mock response from the consumer test, the actual response of the service is now validated. To do this, a test server is typically started with a known data set that ensures the preconditions described in the contract, such as 'a price exists for product 42', are actually met.
If the provider test fails, that means concretely: a consumer's expectation is no longer satisfied by the current implementation, and a deployment would break that consumer. This failure deliberately happens right in the provider's CI pipeline, long before the incompatible change could ever reach production.
<?php
declare(strict_types=1);
namespace Tests\Contract;
use PhpPact\Verifier\Model\VerifierConfig;
use PhpPact\Verifier\InteractionRunner\InteractionRunner;
use PHPUnit\Framework\TestCase;
final class PriceServiceProviderTest extends TestCase
{
public function testSatisfiesConsumerContract(): void
{
$config = (new VerifierConfig())
->setProviderName('price-service')
->setProviderBaseUrl('http://localhost:8080')
->setPactUrl(__DIR__ . '/../pacts/order-service-price-service.json');
$runner = new InteractionRunner($config);
self::assertTrue($runner->verify());
}
}
5. Using the Pact Broker as a central contract store
In small setups with two services, it is enough to share the generated contract as a file between the two repositories. Once more than a handful of services are involved, that quickly becomes unmanageable, which is why using a Pact Broker pays off: a central service that stores published contracts, manages versions, and shows a matrix of which consumer is compatible with which provider version.
The broker also enables 'can-i-deploy' checks directly in the CI pipeline: before a provider is deployed, the pipeline asks the broker whether all known consumers have already been verified against the new version. Only once that check succeeds is the deployment approved, which makes breaking changes practically impossible without anyone having to manually contact every dependent team.
6. A lightweight alternative without an extra library
Not every project needs the full Pact infrastructure with broker and automated verifications right away. A lightweight variant can also be implemented purely with PHPUnit and JSON schema validation: the consumer defines a JSON schema for the expected response structure, and both consumer and provider tests validate their respective data against exactly that schema.
This approach is less powerful than full consumer driven contracts, since it offers no automatic verification matrix across multiple services, but it works well as a starting point for teams with two or three tightly collaborating services who do not yet want to build dedicated contract testing infrastructure but still want to catch silent API divergence early.
<?php
declare(strict_types=1);
namespace Tests\Contract;
use JsonSchema\Validator;
use PHPUnit\Framework\TestCase;
final class PriceResponseSchemaTest extends TestCase
{
public function testProviderResponseMatchesSchema(): void
{
$response = (new PriceServiceClient('http://localhost:8080'))
->fetchRawResponse(productId: 42);
$schema = json_decode(
file_get_contents(__DIR__ . '/../schema/price-response.schema.json'),
);
$validator = new Validator();
$validator->validate($response, $schema);
self::assertTrue($validator->isValid(), implode(', ', array_column($validator->getErrors(), 'message')));
}
}
7. What contract tests deliberately do not check
Contract tests verify structural and partly semantic compatibility, meaning whether fields are present, what types they have, and whether expected status codes come back. They explicitly do not check the business correctness of the logic behind them, for instance whether a calculated price is actually correct. That responsibility still belongs to each service's classic unit and integration tests.
Contract tests likewise do not replace end-to-end tests for critical user flows where multiple services actually have to work together in practice. However, they substantially reduce the need for such expensive, slow end-to-end tests, because pure interface compatibility is already secured at a much faster and cheaper level.
8. Integrating contract tests into the CI pipeline
For contract tests to deliver their benefit, both consumer and provider tests must be firmly integrated into their respective CI pipelines and run automatically on every merge into the main branch. The consumer test runs in the consumer's repository and, on success, automatically publishes the generated contract to the broker or the shared repository.
The provider test runs in the provider's repository and downloads the most recently published contract on every run, instead of using a locally outdated copy. Only that way is it guaranteed that a change on the consumer side, for instance a new expectation for an additional field, is actually checked on the very next provider test run instead of surfacing weeks later.
9. Conclusion: technically securing trust between services
Contract tests translate the informal trust between teams that an API 'will somehow stay compatible' into an automatically verifiable guarantee. For PHP projects with multiple independently deployable services, this is the most effective way to surface breaking changes before deployment instead of only through a production incident.
Getting started pays off incrementally: at first, a simple, jointly maintained contract between two tightly coupled services is enough. Only once the number of service-to-service relationships grows does the investment in a central Pact Broker with automated verification matrices become truly noticeable.
| Test level | What is checked | Speed | Does it replace other tests? |
|---|---|---|---|
| Unit test | Business logic within one service | Very fast | No, the foundation of any test pyramid |
| Contract test (consumer) | Expected response structure from the consumer's view | Fast, runs in isolation | No, adds an interface perspective to unit tests |
| Contract test (provider) | Actual response verified against the same contract | Fast, runs in isolation | No, checks only structural compatibility |
| End-to-end test | Real interaction between multiple actual services | Slow, high infrastructure cost | Substantially reduced by contract tests, not replaced |
Mironsoft
Test automation, Magento quality assurance, and CI integration
Tests that catch real bugs instead of just turning green?
We review existing PHPUnit suites for implementation-detail tests, flaky tests, and missing coverage at critical points, then build a test strategy that provides real confidence with every Magento update.
Test Audit
Reviewing existing suites for mocking antipatterns and blind spots.
Test Strategy
Meaningfully combining unit, integration, and MFTF tests for Magento projects.
CI Integration
Setting up fast, reliable test runs in GitLab CI or GitHub Actions.
10. Summary
Contract Tests: The Key Facts at a Glance
Core problem
Green unit tests per service do not prevent two services from silently drifting apart through a changed API.
Basic principle
Consumer and provider test independently against the same machine-readable contract.
Tooling
pact-foundation/pact-php integrates as an ordinary PHPUnit test suite on both sides.
Limits
Contract tests check structural compatibility, not the business correctness of the logic behind it.