http clients and apis
Contract Testing Orientation
Contract tests check that two systems still agree on how they communicate. For APIs, the contract is usually the request and response shape, status codes, headers, authentication expectations, and error formats.
They are useful when one team owns an API provider and another team owns a consumer, or when one PHP application depends on external services that change over time.
What contract tests protect
Imagine a consumer expects this response:
{
"id": 123,
"name": "Notebook"
}
If the provider changes name to title, the provider may still pass its own unit tests, but the consumer breaks. Contract tests catch that mismatch earlier.
<?php
declare(strict_types=1);
$response = ['id' => 123, 'name' => 'Notebook'];
$requiredKeys = ['id', 'name'];
$missingKeys = array_diff($requiredKeys, array_keys($response));
echo $missingKeys === [] ? 'Response matches contract' : 'Missing keys';
// Prints:
// Response matches contract
Real contract tests are usually more structured than checking two keys, but this shows the basic idea.
Provider and consumer
The provider is the API that serves responses.
The consumer is the application or service that calls the API.
Consumer-driven contract testing starts from what consumers actually need. The provider then proves it still satisfies those expectations.
Contract checks are not full end-to-end tests
Contract tests do not need to click through the UI or exercise every database path. They focus on the boundary between systems.
They sit alongside:
- unit tests for internal logic
- integration tests for database and service wiring
- API tests for real HTTP behaviour
- contract tests for agreement between provider and consumer
Backwards compatibility
Contract testing is closely tied to backwards compatibility.
Usually safe changes:
- adding an optional response field
- accepting a new optional request field
- adding a new endpoint
Usually breaking changes:
- removing a field
- renaming a field
- changing a field type
- making an optional field required
- changing status codes without coordination
- changing error shapes
A small contract assertion
<?php
declare(strict_types=1);
/**
* @param array<string, mixed> $response
* @return list<string>
*/
function productContractErrors(array $response): array
{
$errors = [];
if (!isset($response['id']) || !is_int($response['id'])) {
$errors[] = 'id must be an integer.';
}
if (!isset($response['name']) || !is_string($response['name'])) {
$errors[] = 'name must be a string.';
}
return $errors;
}
echo json_encode(productContractErrors(['id' => 123, 'title' => 'Notebook']), JSON_THROW_ON_ERROR) . PHP_EOL;
// Prints:
// ["name must be a string."]
In a real project, a contract test might validate against OpenAPI, JSON Schema, Pact contracts, or stored examples.
Fixtures and examples
Contract tests often use fixtures: example requests and responses that represent agreed behaviour. Keep them realistic and review changes carefully.
Bad fixtures can give false confidence. A response example with only happy-path fields may miss required error shapes, pagination fields, or null handling.
What to check in a project
Check which API boundaries need contract tests. Internal single-team endpoints may not need the same ceremony as public or cross-team APIs.
Check whether contracts come from OpenAPI, Pact-style files, JSON Schema, or hand-written assertions.
Check that tests cover important error responses, not only successful responses.
Check that contract changes are reviewed by both provider and consumer owners.
Check that CI runs contract checks before incompatible changes are deployed.
What you should be able to do
After this lesson, you should be able to explain provider and consumer contracts, identify breaking API changes, understand where contract tests fit, and write simple assertions that protect response shape expectations.
Practice
Task: Check A Response Contract
Write a small PHP script that checks whether a product API response matches the consumer's expected contract.
Requirements
- Use
declare(strict_types=1);. - Require
idas an integer. - Require
nameas a string. - Include one matching response.
- Include one breaking response where
nameis missing or renamed. - Return useful contract error messages.
- Print both results.
Check Your Work
Run the script and confirm the breaking response fails even if it still looks like valid JSON data.
Show solution
This solution models the consumer's expectation as explicit assertions.
<?php
declare(strict_types=1);
/**
* @param array<string, mixed> $response
* @return list<string>
*/
function productContractErrors(array $response): array
{
$errors = [];
if (!isset($response['id']) || !is_int($response['id'])) {
$errors[] = 'id must be an integer.';
}
if (!isset($response['name']) || !is_string($response['name'])) {
$errors[] = 'name must be a string.';
}
return $errors;
}
$matching = ['id' => 123, 'name' => 'Notebook'];
$breaking = ['id' => 123, 'title' => 'Notebook'];
foreach (['matching' => $matching, 'breaking' => $breaking] as $label => $response) {
$errors = productContractErrors($response);
echo $label . ': ' . ($errors === [] ? 'contract ok' : implode('; ', $errors)) . PHP_EOL;
}
// Prints:
// matching: contract ok
// breaking: name must be a string.
Why This Works
The breaking response is still valid data, but it no longer satisfies the consumer's contract. That is the kind of change contract testing is meant to catch.