Contract Testing: Preventing API Breaking Changes Before They Hit Production

Philip Rehberger Mar 6, 2026 7 min read

Contract testing makes your API agreements machine-verifiable, so breaking changes are caught before they reach production and crash your consumers.

Contract Testing: Preventing API Breaking Changes Before They Hit Production

The Problem with Implicit API Agreements

Your API has consumers. Maybe it's a mobile app, a partner integration, or a frontend team working in another timezone. Each of those consumers builds against your API based on an implicit contract: "If I send this request, I will get back this response."

When you change that contract without warning, things break. Sometimes silently. A field renamed from user_id to userId in a JSON response might not throw an error in your tests, but it will crash mobile apps in production at 2am.

Contract testing is the discipline of making these implicit agreements explicit and machine-verifiable.

What Contract Testing Is

Contract testing verifies that two systems communicating via an API agree on the structure of that communication. There are two roles:

The consumer defines what it expects from the API: which endpoints it calls, what request parameters it sends, and what response fields it relies on.

The provider verifies that it can satisfy those expectations.

The contract is a shared artifact—usually a JSON file—that both sides agree on. When the provider changes, it runs the consumer's contract tests against itself to verify nothing is broken.

Pact: The Standard Contract Testing Tool

Pact is the dominant contract testing framework, available for most languages. The workflow works like this:

  1. Consumer writes a test defining expected interactions
  2. Pact generates a contract file from those tests
  3. Contract is published to a Pact Broker
  4. Provider pulls the contract and runs it against itself
  5. CI gates deployment based on whether contracts pass

Here's a consumer-side contract test in JavaScript:

const { Pact, Matchers } = require('@pact-foundation/pact');

const provider = new Pact({
    consumer: 'ClientPortalApp',
    provider: 'InvoiceService',
    port: 1234,
});

describe('Invoice Service contract', () => {
    before(() => provider.setup());
    after(() => provider.finalize());

    it('returns invoice by ID', async () => {
        await provider.addInteraction({
            state: 'invoice 42 exists',
            uponReceiving: 'a request for invoice 42',
            withRequest: {
                method: 'GET',
                path: '/api/invoices/42',
                headers: { Authorization: 'Bearer token' },
            },
            willRespondWith: {
                status: 200,
                body: {
                    id: 42,
                    amount: Matchers.decimal(1500.00),
                    status: Matchers.term({
                        generate: 'pending',
                        matcher: 'pending|paid|overdue',
                    }),
                    client: {
                        id: Matchers.integer(),
                        name: Matchers.string('Acme Corp'),
                    },
                },
            },
        });

        const invoice = await invoiceClient.getById(42);

        expect(invoice.id).toBe(42);
        expect(invoice.status).toMatch(/pending|paid|overdue/);
    });
});

Notice the use of Matchers rather than exact values. The consumer declares "I need a decimal amount" not "I need exactly 1500.00". This makes contracts resilient to test data variation while still enforcing structure.

Provider Verification in PHP/Laravel

On the provider side, you verify the generated contract against your running application. With php-pact, this runs as a test that spins up your Laravel app and replays the consumer's expected interactions:

class InvoiceServiceContractTest extends TestCase
{
    use RefreshDatabase;

    public function test_satisfies_client_portal_contract(): void
    {
        $config = new VerifierConfig();
        $config->setProviderName('InvoiceService')
            ->setProviderBaseUrl('http://localhost:8000')
            ->setPactBrokerUrl(env('PACT_BROKER_URL'))
            ->setProviderStateEndpoint('http://localhost:8000/_pact/provider_states')
            ->setConsumerVersionSelectors([
                new ConsumerVersionSelector('main', true),
            ]);

        $verifier = new Verifier();
        $result = $verifier->verify($config);

        $this->assertTrue($result, 'Contract verification failed');
    }
}

The provider states ("invoice 42 exists") correspond to states defined in the consumer test. Your provider test is responsible for setting up the right data before each interaction is replayed.

Provider States: The Key to Reliable Contracts

Provider states solve the test data problem in contract testing. The consumer says "given invoice 42 exists, when I request it, I expect...". The provider is responsible for making invoice 42 actually exist when it runs that scenario.

Define provider state handlers in your application:

// routes/pact.php (only loaded in test environment)
Route::post('/_pact/provider_states', function (Request $request) {
    $state = $request->input('state');

    match ($state) {
        'invoice 42 exists' => Invoice::factory()->create([
            'id' => 42,
            'status' => 'pending',
        ]),
        'no invoices exist' => Invoice::query()->delete(),
        'invoice is overdue' => Invoice::factory()->create([
            'status' => 'overdue',
            'due_date' => now()->subDays(30),
        ]),
        default => null,
    };

    return response()->json(['result' => 'success']);
});

This endpoint is called before each interaction, allowing the provider to create exactly the data needed for verification.

OpenAPI-Based Contract Testing

If you use OpenAPI (Swagger) to document your API, schema validation is a lightweight form of contract testing. The spectator package for Laravel validates your API responses against your OpenAPI spec:

use Spectator\Spectator;

class InvoiceApiTest extends TestCase
{
    public function test_get_invoice_matches_openapi_spec(): void
    {
        Spectator::using('api.yaml');

        $invoice = Invoice::factory()->create();

        $this->getJson("/api/invoices/{$invoice->id}")
            ->assertValidRequest()
            ->assertValidResponse(200);
    }
}

This approach is simpler than Pact but less powerful. It verifies your responses match the documented schema, but it doesn't give consumers an active voice in defining what they need. It's excellent for catching regressions against your own documentation.

Can I Deploy This? The Critical Question

The most valuable question contract testing answers is: "If I deploy this change, will I break my consumers?"

Pact's "can I deploy" tool queries the broker to determine if all consumers' contracts still pass against your latest provider version:

# In your CI pipeline, before deploying
pact-broker can-i-deploy \
  --pacticipant InvoiceService \
  --version $GIT_SHA \
  --to-environment production

If any consumer contract is broken, deployment is blocked. This is the contract testing superpower: a hard gate that prevents breaking changes from reaching production.

Handling Intentional Breaking Changes

Sometimes you need to make a breaking change. Contract testing doesn't prevent this; it makes it coordinated.

Expansion and contraction pattern:

  1. Add the new field or endpoint alongside the old one (expand)
  2. Notify consumers and wait for them to update their contracts
  3. Remove the old field once all consumer contracts no longer reference it (contract)

For example, renaming user_id to userId:

// Expansion phase: return both field names
return [
    'user_id' => $invoice->user_id,   // old consumers still work
    'userId'  => $invoice->user_id,   // new consumers use this
    'amount'  => $invoice->amount,
];

Once all consumer contracts use userId and none reference user_id, remove the deprecated field in a subsequent deployment.

Setting Up a Pact Broker

The Pact Broker is where contracts are published and shared. Self-host with Docker:

# docker-compose.yml
services:
  postgres:
    image: postgres:15
    environment:
      POSTGRES_PASSWORD: password
      POSTGRES_DB: pact_broker

  pact-broker:
    image: pactfoundation/pact-broker:latest
    depends_on: [postgres]
    ports:
      - "9292:9292"
    environment:
      PACT_BROKER_DATABASE_URL: postgresql://postgres:password@postgres/pact_broker

Or use PactFlow, the managed cloud version, which adds webhooks, analytics, and a polished UI.

When Contract Testing Pays Off Most

Contract testing is most valuable when:

  • Multiple teams consume the same API
  • Mobile apps consume your API and cannot be force-updated
  • You deploy provider and consumers independently
  • You've been burned by breaking API changes before

It's less valuable when:

  • Your entire system is deployed as a monolith
  • The same team owns both provider and consumer
  • All consumers are web clients you fully control

Getting Started Without Full Pact Setup

If Pact feels like too much infrastructure to start, begin with API response schema validation in your existing tests:

public function test_invoice_response_schema(): void
{
    $invoice = Invoice::factory()->create();

    $response = $this->getJson("/api/invoices/{$invoice->id}");

    $response->assertJsonStructure([
        'data' => [
            'id',
            'amount',
            'status',
            'client' => ['id', 'name'],
            'line_items' => [
                '*' => ['description', 'quantity', 'unit_price']
            ],
        ]
    ]);
}

This won't catch consumer-specific requirements, but it will catch regressions in your own API shape. It's the pragmatic first step toward full contract testing.

Practical Takeaways

  • Contract testing prevents breaking changes by making API agreements explicit and machine-verifiable
  • Pact is the standard tool; the Pact Broker is where contracts are shared between teams
  • Provider states solve the test data problem; invest time in setting them up correctly
  • Use "can I deploy" as a hard CI gate before production deployments
  • For breaking changes, use the expansion-contraction pattern to migrate consumers safely

Need help building reliable systems? We help teams architect software that scales. scopeforged.com

Share this article

Related Articles

Need help with your project?

Let's discuss how we can help you build reliable software.