DEV Community

Cover image for Contract Testing for PHP Microservices with Pact
Patoliya Infotech
Patoliya Infotech

Posted on

Contract Testing for PHP Microservices with Pact

The Day Everything Broke (And Nobody Knew Why)

Picture this: it's a Tuesday morning, your order service is throwing 500 errors in production, and your team is frantically checking logs. After 45 minutes of panic, you trace the root cause, the inventory service team quietly changed the response structure of their /products/{id} endpoint. They added a nested object where a flat field used to live. Their unit tests passed. Your unit tests passed. But in production? Complete chaos.

This is the microservices tax nobody puts on the architecture diagram.

When you split a monolith into services, you gain autonomy and scalability. But you also introduce invisible contracts, informal understandings between services about what data looks like, what fields are required, and what HTTP status codes mean. As long as those stay in sync, everything's fine. The moment they drift, you get integration failures that are painful to debug and embarrassing to explain to stakeholders.

The industry has been solving this problem for years. The current best answer is contract testing and in this post, we're going to walk through exactly how to implement it in PHP using the Pact framework.

Why Traditional Testing Falls Short in Distributed Systems

Before jumping to the solution, let's be honest about why the software testing strategies you already use aren't enough on their own.

Unit Tests

Unit tests are great. They're fast, isolated, and give you confidence in individual functions and classes. But that isolation is also their limitation. When you mock an HTTP call to the inventory service in your order service tests, you're testing against a fake. If the real inventory service changes its API, your mocks don't know, and your tests still pass.

Unit tests tell you your code works. They don't tell you your services work together.

Integration Tests

Integration tests wire real services together. They catch what unit tests miss, but at a serious cost. They're slow to run, require orchestrated environments (databases, message queues, dependent services), and are notoriously brittle. One flaky network call or misconfigured service and your pipeline is red.

In a system with 10+ microservices, full integration test suites can take 30–60 minutes to run. Teams start skipping them, or running them only before releases, which is exactly when it's too late to catch problems cheaply.

End-to-End Tests

E2E tests simulate real user journeys across the full system. Valuable, but expensive. Setting up a full environment with every service running is operationally complex. Debugging failures requires tracing through multiple service logs. And covering every API interaction permutation is practically impossible.

The Core Gap

All three approaches share a common weakness in distributed systems: they don't explicitly verify the interface contract between services. That contract lives implicitly in your code, your documentation, or worse, in someone's head.

Contract testing makes that implicit agreement explicit, versioned, and automatically verified.

What Is Contract Testing?

Contract testing is a technique where each pair of communicating services defines a formal agreement about what their interaction looks like and both sides independently verify they're honoring it.

Here's a simple analogy. Imagine you're building a restaurant ordering system. You (the consumer, the frontend app) need a menu. You tell the kitchen (the provider, the menu service): "When I ask for /menu, I expect to get back a JSON array of items, each with id, name, and price."

That expectation becomes a contract. The kitchen doesn't need to know anything about you to verify it, they just need to confirm their service can fulfill exactly that response structure.

Consumer-Driven Contracts

The most powerful flavor of contract testing is consumer-driven. Instead of the provider defining what they'll return and hoping consumers can handle it, consumers define what they need and providers verify they can satisfy it.

This flips the power dynamic in a healthy way. Teams discover breaking changes before deployment, not after. The consumer's needs are the source of truth, which prevents providers from shipping changes that silently break downstream services.

Introducing Pact

Pact is the de facto standard for consumer-driven contract testing. It started in the Ruby ecosystem but has grown into a multi-language framework with strong support for PHP, Go, Java, Node.js, Python, and more.

Core Concepts

Consumer - The service that calls another service's API. In microservices, this could be your order service calling the inventory service.

Provider - The service exposing the API. It's responsible for fulfilling the contract the consumer has defined.

Pact File - A JSON file generated from consumer tests. It describes the interactions the consumer expects: the request it will make and the response it needs. This file is the contract.

Pact Broker - An optional (but highly recommended) centralized server that stores pact files, tracks which versions have been verified, and gives you a can-i-deploy check to confirm it's safe to release a version.

Why Teams Adopt Pact

The core workflow is elegant: consumer writes tests that generate a pact file → consumer shares that file with the provider → provider runs verification tests against their live service to confirm they satisfy the contract. No shared test environment needed. No coordinating deploys. Two teams can work independently and still trust their integration.

Setting Up Pact in PHP

The PHP Pact implementation is maintained as pact-foundation/pact-php. Let's set up a realistic scenario: an OrderService (consumer) that calls a ProductService (provider) to fetch product details.

Prerequisites

  • PHP 8.1+
  • Composer
  • A PHPUnit setup

Install via Composer

composer require --dev pact-foundation/pact-php
Enter fullscreen mode Exit fullscreen mode

This pulls in everything you need: the FFI-based Pact library, PHPUnit integration, and the mock server utilities.

Project Structure

/order-service
  /src
    ProductServiceClient.php
  /tests
    /Consumer
      ProductServiceConsumerTest.php
    /Provider
      ProductServiceProviderTest.php
  composer.json
Enter fullscreen mode Exit fullscreen mode

Environment Configuration

Create a .env.test (or configure in your CI pipeline):

PACT_MOCK_SERVER_HOST=localhost
PACT_MOCK_SERVER_PORT=7200
PACT_OUTPUT_DIR=./pacts
PACT_CONSUMER_NAME=OrderService
PACT_PROVIDER_NAME=ProductService
Enter fullscreen mode Exit fullscreen mode

Writing a Consumer Test

This is where the contract is defined. You're writing a test that says: "When my OrderService sends this request to ProductService, it expects this response."

The Client Class

First, here's the simple HTTP client you want to test:

<?php

namespace App\Services;

use GuzzleHttp\Client;

class ProductServiceClient
{
    private Client $client;

    public function __construct(string $baseUri)
    {
        $this->client = new Client(['base_uri' => $baseUri]);
    }

    public function getProduct(int $productId): array
    {
        $response = $this->client->get("/products/{$productId}");
        return json_decode($response->getBody()->getContents(), true);
    }
}
Enter fullscreen mode Exit fullscreen mode

The Consumer Pact Test

<?php

namespace Tests\Consumer;

use App\Services\ProductServiceClient;
use PhpPact\Consumer\InteractionBuilder;
use PhpPact\Consumer\Matcher\Matcher;
use PhpPact\Consumer\Model\ConsumerRequest;
use PhpPact\Consumer\Model\ProviderResponse;
use PhpPact\Standalone\MockService\MockServerConfig;
use PhpPact\Standalone\MockService\MockServerEnvConfig;
use PHPUnit\Framework\TestCase;

class ProductServiceConsumerTest extends TestCase
{
    private InteractionBuilder $builder;
    private MockServerConfig $config;

    protected function setUp(): void
    {
        $this->config = new MockServerEnvConfig();

        $this->builder = new InteractionBuilder($this->config);
    }

    public function testGetProductById(): void
    {
        $matcher = new Matcher();

        // Define the request the consumer will make
        $request = new ConsumerRequest();
        $request
            ->setMethod('GET')
            ->setPath('/products/42')
            ->addHeader('Accept', 'application/json');

        // Define the response the consumer expects
        $response = new ProviderResponse();
        $response
            ->setStatus(200)
            ->addHeader('Content-Type', 'application/json')
            ->setBody([
                'id'    => $matcher->integer(42),
                'name'  => $matcher->like('Blue Widget'),
                'price' => $matcher->decimal(19.99),
                'stock' => $matcher->integer(100),
            ]);

        // Register the interaction
        $this->builder
            ->given('A product with ID 42 exists')
            ->uponReceiving('A GET request for product 42')
            ->with($request)
            ->willRespondWith($response);

        // Execute against the mock server (which Pact spins up)
        $mockServerBaseUri = "http://{$this->config->getHost()}:{$this->config->getPort()}";
        $client = new ProductServiceClient($mockServerBaseUri);

        $product = $client->getProduct(42);

        // Assert your consumer handles the response correctly
        $this->assertArrayHasKey('id', $product);
        $this->assertArrayHasKey('name', $product);
        $this->assertArrayHasKey('price', $product);
        $this->assertIsNumeric($product['price']);

        // Tell Pact the interaction was successful
        $this->builder->verify();
    }

    public function testGetNonExistentProduct(): void
    {
        $request = new ConsumerRequest();
        $request
            ->setMethod('GET')
            ->setPath('/products/9999')
            ->addHeader('Accept', 'application/json');

        $response = new ProviderResponse();
        $response
            ->setStatus(404)
            ->addHeader('Content-Type', 'application/json')
            ->setBody([
                'error'   => 'Product not found',
                'code'    => 'PRODUCT_NOT_FOUND',
            ]);

        $this->builder
            ->given('A product with ID 9999 does not exist')
            ->uponReceiving('A GET request for a non-existent product')
            ->with($request)
            ->willRespondWith($response);

        $mockServerBaseUri = "http://{$this->config->getHost()}:{$this->config->getPort()}";
        $client = new ProductServiceClient($mockServerBaseUri);

        $this->expectException(\GuzzleHttp\Exception\ClientException::class);
        $client->getProduct(9999);

        $this->builder->verify();
    }

    protected function tearDown(): void
    {
        // Pact writes the contract file to PACT_OUTPUT_DIR after verify
        $this->builder->finalize();
    }
}
Enter fullscreen mode Exit fullscreen mode

What Just Happened?

When you run this test, Pact does the following:

  1. Spins up a lightweight mock HTTP server on localhost:7200
  2. Registers your defined interaction (request → response mapping)
  3. Your real client code calls the mock server
  4. Pact verifies that the request your client made matched what you said it would
  5. On finalize(), it writes a OrderService-ProductService.json pact file to your output directory

That JSON file is the contract. It describes every interaction your consumer depends on.

A Note on Matchers

Notice the use of $matcher->like(), $matcher->integer(), and $matcher->decimal(). These are flexible matchers, they validate the type of the response field rather than an exact value.

This is important. If you hard-code expected values ('name' => 'Blue Widget'), your contract becomes too rigid and will break whenever test data changes. Matchers keep contracts focused on structure, not state.

Writing a Provider Test

The provider test lives in the ProductService codebase. Its job is to fetch the pact file generated by the consumer and verify that the real ProductService satisfies every interaction defined in it.

Provider Verification Test

<?php

namespace Tests\Provider;

use PhpPact\Standalone\ProviderVerifier\Model\VerifierConfig;
use PhpPact\Standalone\ProviderVerifier\Verifier;
use PHPUnit\Framework\TestCase;

class ProductServiceProviderTest extends TestCase
{
    public function testVerifyConsumerContracts(): void
    {
        // Boot your real application (or use a test server)
        // Your product service must be running at this URL
        $providerBaseUrl = 'http://localhost:8080';

        $config = new VerifierConfig();
        $config
            ->setProviderName('ProductService')
            ->setProviderVersion(getenv('APP_VERSION') ?: '1.0.0')
            ->setProviderBranch(getenv('GIT_BRANCH') ?: 'main')
            ->setProviderBaseUrl($providerBaseUrl)
            // Load pact files from local directory (or Pact Broker URL)
            ->addCustomProviderHeader('X-Internal-Auth', 'test-token');

        $verifier = new Verifier($config);

        // Point to where pacts are stored local path or Pact Broker
        $verifier->addLocalPactFilesFromDirectory(__DIR__ . '/../../pacts');

        // Run verification - this replays each interaction against your real service
        $result = $verifier->verify();

        $this->assertTrue($result, 'Provider failed to satisfy consumer contracts');
    }
}
Enter fullscreen mode Exit fullscreen mode

Handling Provider States

Notice the given('A product with ID 42 exists') in the consumer test. That's a provider state - it tells the provider what data must exist for this interaction to be valid.

Providers handle this via a state change endpoint. Add a special endpoint to your test environment (never production) that sets up the required state:

<?php

// In your test bootstrap or a dedicated test controller
// This endpoint is called by Pact before replaying each interaction

Route::post('/_pact/provider-states', function (Request $request) {
    $state = $request->json('state');
    $action = $request->json('action', 'setup'); // setup or teardown

    if ($action === 'setup') {
        match ($state) {
            'A product with ID 42 exists' => Product::factory()->create([
                'id'    => 42,
                'name'  => 'Blue Widget',
                'price' => 19.99,
                'stock' => 100,
            ]),
            'A product with ID 9999 does not exist' => Product::where('id', 9999)->delete(),
            default => null,
        };
    }

    return response()->json(['result' => 'State set up successfully']);
});
Enter fullscreen mode Exit fullscreen mode

Register this state endpoint in your verifier config:

$config->setStateChangeUrl('http://localhost:8080/_pact/provider-states');
$config->setStateChangeTeardown(true); // also call teardown after each interaction
Enter fullscreen mode Exit fullscreen mode

Edge Cases to Watch

Authentication: If your API requires auth headers, configure the verifier to send them. Most teams use a special test token that bypasses real auth in the test environment.

Async interactions: For message-based contracts (Kafka, RabbitMQ), Pact supports message pacts. The setup differs from HTTP pacts but follows the same consumer-first philosophy.

Wildcard paths: If your consumer tests a path like /products/42, make sure your provider's router handles the numeric ID correctly in test mode.

Running & Automating Tests

Local Execution

# In OrderService (consumer) generates the pact file
vendor/bin/phpunit tests/Consumer/

# Copy the generated pact file to ProductService's pacts/ directory
# Or publish it to Pact Broker (recommended)

# In ProductService (provider) verifies the contract
vendor/bin/phpunit tests/Provider/
Enter fullscreen mode Exit fullscreen mode

CI/CD Integration (GitHub Actions Example)

# .github/workflows/consumer-tests.yml
name: Consumer Contract Tests

on: [push, pull_request]

jobs:
  consumer-pact:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Set up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'

      - name: Install dependencies
        run: composer install

      - name: Run consumer pact tests
        env:
          PACT_CONSUMER_NAME: OrderService
          PACT_PROVIDER_NAME: ProductService
          PACT_OUTPUT_DIR: ./pacts
          PACT_MOCK_SERVER_HOST: localhost
          PACT_MOCK_SERVER_PORT: 7200
          APP_VERSION: ${{ github.sha }}
        run: vendor/bin/phpunit tests/Consumer/

      - name: Publish pact to Pact Broker
        run: |
          vendor/bin/pact-broker publish ./pacts \
            --broker-base-url ${{ secrets.PACT_BROKER_URL }} \
            --broker-token ${{ secrets.PACT_BROKER_TOKEN }} \
            --consumer-app-version ${{ github.sha }} \
            --branch ${{ github.ref_name }}
Enter fullscreen mode Exit fullscreen mode
# .github/workflows/provider-verification.yml
name: Provider Contract Verification

on: [push, pull_request]

jobs:
  provider-pact:
    runs-on: ubuntu-latest

    services:
      product-service:
        image: your-registry/product-service:latest
        ports:
          - 8080:8080

    steps:
      - uses: actions/checkout@v3

      - name: Set up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'

      - name: Install dependencies
        run: composer install

      - name: Verify consumer contracts
        env:
          PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
          APP_VERSION: ${{ github.sha }}
          GIT_BRANCH: ${{ github.ref_name }}
        run: vendor/bin/phpunit tests/Provider/

      - name: Can I Deploy?
        run: |
          vendor/bin/pact-broker can-i-deploy \
            --pacticipant ProductService \
            --version ${{ github.sha }} \
            --to-environment production \
            --broker-base-url ${{ secrets.PACT_BROKER_URL }} \
            --broker-token ${{ secrets.PACT_BROKER_TOKEN }}
Enter fullscreen mode Exit fullscreen mode

Pact Broker

The Pact Broker is a server that acts as the central registry for all your contracts. It provides:

  • A searchable history of all pact versions
  • Verification results per provider and consumer version
  • The can-i-deploy check, the killer feature that tells you definitively whether a specific version of your service is compatible with everything in a target environment
  • A visual dependency graph of your services

You can self-host the open-source Pact Broker or use the managed PactFlow service.

Best Practices

Version your contracts alongside your code. Pact files should be checked into version control or published to a broker with a version tag that corresponds to your application version (e.g., git SHA). This makes the relationship between code versions and contracts explicit.

Keep interactions focused on what the consumer actually uses. Don't include every field the provider returns, only what your consumer processes. Over-specified contracts create unnecessary coupling and will break when the provider adds new optional fields your consumer doesn't even use.

Use provider states thoughtfully. State descriptions should be descriptive and owned collaboratively between teams. Vague states like 'products exist' lead to ambiguity. Precise states like 'A product with ID 42 exists with stock > 0' make setup unambiguous.

Communicate across team boundaries. Contract testing is a collaboration tool as much as a technical one. When a consumer adds a new interaction, the provider team should be notified. Use the Pact Broker's webhook support to trigger provider verification pipelines automatically when new pacts are published.

Don't skip the can-i-deploy check. This is what prevents you from deploying a provider change that breaks a consumer. Make it a hard gate in your release pipeline.

⚡ Common Pitfalls

Over-Specifying Contracts

The most common mistake new teams make is being too precise in their consumer tests. Matching exact strings where type matching would suffice, or including response fields the consumer never actually reads.

Tight contracts mean more maintenance burden. Every time the provider legitimately changes a field name or adds properties, you'll be updating contracts even though nothing functionally broke.

Fix: Use matchers liberally. Only assert on fields your consumer code actually accesses.

Ignoring Edge Cases

It's easy to only write the happy path contract. But what happens when a product is out of stock? What if the request is missing an auth header? What if the upstream database is unavailable?

Providers need contracts that cover error responses too. If your consumer code handles a 404 differently from a 503, both need to be in the contract.

Treating Contract Tests as a Full Replacement for Integration Tests

Contract testing proves that services can communicate correctly in isolation. It doesn't prove your entire system behaves correctly end-to-end.

Contract tests answer: "Can OrderService and ProductService talk to each other?" Integration tests answer: "Does placing an order actually work?" You still need some integration and E2E coverage, just not as much.

Neglecting State Cleanup

Provider state endpoints that don't clean up after themselves lead to test pollution. One interaction's setup corrupts another's assumptions. Always implement both setup and teardown handlers and enable setStateChangeTeardown(true) in your verifier.

When to Use Contract Testing (and When Not To)

Ideal Scenarios

Contract testing shines when you have multiple teams working on different services that communicate via APIs. The organizational boundary is what makes contract testing valuable, it formalizes an interface agreement that would otherwise live in Slack messages and tribal knowledge.

It's also valuable when services deploy independently. If your consumer and provider always deploy together in lockstep, you can catch integration issues in a standard integration test suite. But if they ship separately on different cadences, contract testing becomes your safety net.

Services that have many consumers benefit enormously. A shared API consumed by 5 internal services needs contract tests. Without them, every change requires coordinating manual testing across 5 teams, or just crossing your fingers.

When Simpler Works Better

If you have a small team, a monorepo, and services that always deploy together, the overhead of Pact might not be worth it. A well-maintained integration test suite could give you the same confidence with less setup.

Similarly, if you're building a public API for external consumers, contract testing isn't the right fit, you can't control what third-party consumers write in their Pact files. Versioning, API changelogs, and OpenAPI specifications are more appropriate tools there.

Real-World Implementation

Contract testing isn't just a theory exercise, it saves production incidents. Teams that consistently apply consumer-driven contracts across their microservices architectures report dramatically fewer integration-related incidents and faster, more confident release cycles.

In practice, teams like those at Patoliya Infotech implement contract testing as a first-class step in their microservices development workflow, making sure both consumer expectations and provider capabilities are verified automatically before anything touches production. This kind of discipline, built early into the engineering culture, is what separates systems that scale gracefully from ones that become progressively more fragile with each new service added.

If you're scaling a distributed PHP system and looking for the right architectural patterns and testing strategies, Patoliya Infotech specializes in building maintainable, production-grade microservices architectures with test strategies that don't break down at scale.

Conclusion

Let's recap what we covered:

The problem: Distributed systems create implicit contracts between services. Traditional testing strategies don't verify these contracts, leading to integration failures that are expensive to catch and embarrassing to explain.

The solution: Consumer-driven contract testing with Pact makes those implicit contracts explicit, versioned, and automatically verified. Consumers define what they need. Providers prove they can deliver it. Both teams move independently with confidence.

The implementation: PHP's pact-foundation/pact-php library gives you a solid foundation. Consumer tests generate pact files. Provider verification tests replay those interactions against your real service. The Pact Broker centralizes everything and gives you the can-i-deploy gate that makes confident, independent deployments possible.

The mindset shift: Contract testing isn't just a technical practice, it's a communication protocol between teams. The test files are documentation that never goes stale, because they're enforced automatically.

The real payoff isn't just catching bugs. It's the confidence to ship independently, to refactor without fear, and to onboard new engineers who can read the contracts and immediately understand how services are supposed to behave.

The next time someone changes an API and doesn't tell you, you'll already know, before it ever reaches production.

Have questions about implementing Pact in your PHP stack, or want to share how your team approaches contract testing? Drop a comment below, the discussion is always better than the article.

Top comments (0)