Implementing Contract Tests Between Services in PHP with PHPUnit
AI generated
@test
assert
PHPUnit · Contract Tests · PHP
Contract Tests
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.

15 min read Consumer driven contracts API compatibility Service boundaries

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.

11. FAQ: Contract Tests: The Key Facts at a Glance

1What is the difference between contract tests and end-to-end tests?
Contract tests verify each service in isolation against a shared contract, end-to-end tests have real services actually interact. Contract tests are faster but do not fully replace end-to-end tests.
2What does consumer driven contract testing mean in practice?
The consumer of an API states its expectations about the response as a machine-readable contract, which is then verified against the provider's real implementation.
3Which PHP library is typically used for contract tests?
pact-foundation/pact-php is the most common implementation of the Pact standard for PHP and integrates directly into PHPUnit test suites.
4What is a Pact Broker?
A central service that stores published contracts, manages versions, and uses can-i-deploy checks to prevent a provider from deploying incompatible changes.
5Do you strictly need Pact for contract tests?
No. A lightweight alternative is JSON schema validation, where consumer and provider tests check their respective data against the same schema file.
6What do contract tests not check?
They do not check the business correctness of the logic, for instance whether a calculated price is right. Classic unit and integration tests remain responsible for that.
7How often should contract tests run?
On every merge into the main branch, in both the consumer and provider repositories, so changes surface immediately instead of weeks later.
8Do contract tests fully replace end-to-end tests?
No, but they substantially reduce the need for expensive end-to-end tests, because pure interface compatibility is already secured at a faster level.
9At what project size does a Pact Broker become worthwhile?
With two tightly coupled services, a simple shared contract is often enough. Once several service-to-service relationships exist, a central broker with a verification matrix becomes noticeably more valuable.
10How exactly does can-i-deploy prevent breaking changes?
Before a deployment, the CI pipeline asks the broker whether all known consumers have already been successfully verified against the new provider version, and blocks the deployment otherwise.