Contract Testing External APIs in PHP with Pact (Real Laravel Example)

Testing integrations with external APIs is one of the most fragile parts of any web application. In theory, we write tests, mock HTTP clients, and feel confident. In practice, APIs change, fields disappear, status codes shift, and production breaks anyway.

I learned this the hard way.

In one project, all my tests were green. My mocks returned exactly what I expected. A week later, the external API removed one field from the response. My mocks didn’t know about the change. Production did.

Mocks told me everything was fine. Reality disagreed.

This is exactly the kind of problem contract testing is meant to solve. In this article, I’ll show you how to use contract testing for external APIs in PHP with Pact, using a real-world Laravel example.


Mocking external APIs is useful. I still do it. It makes tests fast, deterministic, and cheap. But mocks have one fatal flaw:

They only test your assumptions about the API, not the API itself.

Typical problems I’ve seen in production:

  • The API removes or renames a field.
  • A nested structure changes shape.
  • The API starts returning 404 instead of 200 in some edge cases.
  • The API adds a required field to the request body.

Your mocks will never catch this unless you manually update them. That means mocks alone can silently drift away from reality.


Contract testing sits between mocking and full integration tests.

Instead of saying:

“This is what I think the API returns,”

you say:

“This is the contract between my app (consumer) and the API (provider).”

With consumer-driven contract testing:

  • The consumer defines what it expects from the API.
  • The provider must verify that it actually fulfills this contract.

If the provider changes something that breaks the contract, tests fail before production breaks.

This turns breaking API changes from a runtime surprise into a build-time failure.

Diagram: How contract testing works between a Laravel consumer, Pact mock server, and the real API provider.


Let’s assume a Laravel application integrates with an external billing API:

GET /api/customers/{id}

Response:
{
  "id": 123,
  "email": "john@example.com",
  "is_active": true
}
JSON

Your Laravel service:

class BillingClient
{
    public function getCustomer(int $id): array
    {
        $response = Http::get("https://billing.example.com/api/customers/{$id}");

        return $response->json();
    }
}
PHP

Your application relies on:

  • email
  • is_active

If the provider removes is_active, your app breaks. Mocks won’t catch it. Contract testing will.


Install Pact dependencies for your test environment:

composer require --dev pact-foundation/pact-php

You’ll also need the Pact CLI running locally or in CI (usually via Docker).


The goal: define what your Laravel app expects from the API.

Example consumer test:

public function test_it_fetches_customer_from_billing_api()
{
    $builder = new InteractionBuilder();
    
    $builder
        ->given('Customer 123 exists')
        ->uponReceiving('A request for customer 123')
        ->with([
            'method' => 'GET',
            'path'   => '/api/customers/123',
        ])
        ->willRespondWith([
            'status' => 200,
            'headers' => ['Content-Type' => 'application/json'],
            'body' => [
                'id' => 123,
                'email' => 'john@example.com',
                'is_active' => true,
            ],
        ]);

    $builder->verify(function () {
        $client = new BillingClient();
        $customer = $client->getCustomer(123);

        $this->assertSame('john@example.com', $customer['email']);
        $this->assertTrue($customer['is_active']);
    });
}
PHP

This test:

  • defines the expected request
  • defines the expected response
  • generates a contract file (Pact file)

This contract is the single source of truth between consumer and provider.


On the provider side, the API team runs Pact verification:

pact-verifier --provider-base-url=http://localhost:8000 \
 --pact-url=./pacts/billing-consumer.json
Bash

If the API no longer returns is_active, verification fails.

This is the key difference from mocks:

  • mocks only test the consumer
  • contract testing tests the agreement

This is where contract testing becomes powerful.

Typical CI flow:

  1. Consumer tests generate Pact contracts.
  2. Provider pipeline verifies contracts.
  3. Pipeline fails if contracts are broken.

This creates a safety net:

API changes cannot be deployed if they break existing consumers.


Over-Specifying Responses

Don’t lock down every field if you don’t need it.
Only define what your app actually uses.

Only Testing Happy Paths

Include error contracts:

  • 404 responses
  • validation errors
  • unauthorized responses

Not Versioning Contracts

Treat contracts like code. Version them.

Not Enforcing Contracts in CI

If no pipeline enforces them, contract testing is just documentation.


Contract testing is worth it when:

  • multiple teams own different services
  • APIs evolve frequently
  • breaking changes are expensive

It’s probably overkill when:

  • the integration is trivial
  • you don’t control the provider
  • the API is stable and rarely changes

Contract testing is a scalpel, not a hammer. Use it where API stability matters.


This is how I think about testing layers in real projects:

  • Unit tests – business logic
  • Feature tests – HTTP flows
  • Mocks – isolate slow or unstable services
  • Contract tests – protect integrations from breaking changes

Mocks keep your tests fast.
Contract tests keep your integrations honest.

They solve different problems and work best together.


Mocking external APIs is necessary — but it’s not sufficient.

If your application depends on external services and you’ve ever been surprised by a breaking API change in production, contract testing is the missing safety net.

You don’t need to contract-test everything. Start with one critical integration. Add one contract. Let your CI enforce it. The first time a breaking change is caught before deployment, contract testing pays for itself.

Leave a Reply

Your email address will not be published. Required fields are marked *