An HTTP product catalogue and an in-memory fake both implement this interface:
interface ProductCatalogue
{
/** @throws ProductNotFound */
public function priceCents(string $sku): int;
}Both return 10000 for CHAIR-BLACK. Both make the checkout test green. Both
pass PHP’s signature checks.
Now seed one divergence. When the product is absent, the HTTP adapter throws
ProductNotFound while the fake returns zero. The interface remains satisfied,
and the familiar checkout test still passes because it never asks for a
missing SKU.
Method compatibility is not behavioral compatibility. When callers rely on the same output and failure semantics from several implementations, run one consumer-shaped contract suite against all of them. The suite makes the shared promise executable; independent implementation tests do not.
The method signature leaves the missing product undecided
PHP interfaces require implementations to provide compatible methods. That gives static analysis and the runtime something concrete to enforce: the public method exists, accepts a string, and declares an integer return type.
It cannot settle what MISSING-SKU means. Several implementations fit the same
signature:
return 0;throw ProductNotFound::forSku($sku);throw new RuntimeException('Provider unavailable');Those results are not interchangeable to a caller. Zero can enter price
arithmetic. ProductNotFound can produce a normal unavailable-product path. A
provider failure may require retry or operator attention. The @throws tag
communicates intent to people and analyzers, but PHP does not use it to reject
the zero-returning implementation.
This is the failure-semantics part of Liskov substitution. The useful question is not whether two classes share an interface. It is whether a caller can replace one with the other without changing the caller-visible behavior it depends on.
Write the consumer promise once
The reproduction gives the catalogue two shared examples:
abstract class ProductCatalogueContract extends TestCase
{
abstract protected function catalogue(): ProductCatalogue;
public function test_known_product_returns_its_exact_price_in_cents(): void
{
self::assertSame(
10_000,
$this->catalogue()->priceCents('CHAIR-BLACK'),
);
}
public function test_unknown_product_has_the_shared_failure_semantics(): void
{
$this->expectException(ProductNotFound::class);
$this->catalogue()->priceCents('MISSING-SKU');
}
}Concrete test cases provide only the implementation:
final class InMemoryProductCatalogueContractTest
extends ProductCatalogueContract
{
protected function catalogue(): ProductCatalogue
{
return new InMemoryProductCatalogue([
'CHAIR-BLACK' => 10_000,
]);
}
}The HTTP version constructs HttpProductCatalogue instead. It does not replace
the expected exception, skip an example, or branch inside the contract. The
same methods run against both implementations.
Inheritance is incidental here. A trait, helper, or generated test matrix can provide the same reuse. The important property is that one definition owns the consumer expectations. PHPUnit’s test organization guide describes tests and suites as composable; the fixture uses that composition to make drift visible in either direction.
In clean mode, both two-example suites pass. That is modest evidence, but it is specific: both implementations return the named price and throw the named exception for the same inputs.
A fake can agree with the happy path and violate the contract
The first divergence changes only the fake’s missing-SKU branch:
if (getenv('FAKE_DEFECT') === '1') {
return 0;
}Its known product is untouched. Two consumer controls still calculate a
subtotal of 20000, one through the fake and one through the HTTP adapter. The
HTTP contract also stays green.
The fake contract fails:
Tests\InMemoryProductCatalogueContractTest::
test_unknown_product_has_the_shared_failure_semantics
Failed asserting that exception of type
"App\Catalogue\ProductNotFound" is thrown.The retained command finished with exit 1 in 273 milliseconds. The HTTP contract passed in 298 milliseconds, and the known-SKU controls passed in 286 milliseconds. These durations include starting a disposable container and local provider. They identify the runs, not an HTTP-versus-memory performance result.
The green controls matter. They show why interface conformance and the existing happy path were insufficient. Neither exercised the semantic partition which drifted.
The same contract can reject production-adapter drift
A contract suite for fakes only would still leave an asymmetry: the fake must match a production adapter whose own behavior is assumed correct. The shared suite removes that privilege.
The production adapter talks through Laravel’s HTTP client to a real local
provider process. The provider returns 10000 for the known product and 404
for an unknown one. Laravel documents that its HTTP client does not throw for
4xx and 5xx responses by default, so the adapter owns
the translation:
if ($response->status() === 404) {
throw ProductNotFound::forSku($sku);
}
$response->throw();The specific 404 becomes the port’s absence meaning. Other client and server
errors follow Laravel’s explicit throw() path rather than being collapsed
into “not found.”
Now make the adapter return zero for that 404. The mirrored result appears:
| Seeded divergence | HTTP contract | Fake contract | Known-SKU controls |
|---|---|---|---|
| fake returns zero | pass | fail | pass |
| HTTP adapter returns zero | fail | pass | pass |
The HTTP contract fails with the same missing-exception assertion in 328 milliseconds. The fake contract passes in 283 milliseconds, and both consumer controls pass in 291 milliseconds.
The contract does not care whether the divergence came from array lookup or HTTP status translation. It sees the behavior the caller sees. That is what makes it shared.
Shared behavior is smaller than the adapter test inventory
The contract should not become a dumping ground for every test involving the interface. The catalogue example separates its evidence like this:
| Behavior or risk | Evidence owner |
|---|---|
| known SKU returns exact integer cents | shared contract |
unknown SKU throws ProductNotFound | shared contract |
provider 404 maps to absence | HTTP adapter through the shared contract |
| malformed provider JSON is rejected | HTTP-adapter-specific test |
| retry, timeout, and authentication policy | integration or resilience test |
| fake seed data is deterministic | fake setup |
A fake cannot receive malformed provider JSON. Making it run an assertion about
that payload would leak HTTP details into the port contract. Conversely, an
HTTP-only 404 test would not stop the fake from returning zero. Put an example
in the shared suite only when every substitute must preserve it for the caller.
The local provider also does not turn this fixture into proof about DNS, TLS, remote availability, credentials, or deployment configuration. It exists to put real status parsing and Laravel’s response behavior inside the test boundary. Wider operational risks need wider evidence.
A finite suite measures named behavior, not total substitutability
Two passing examples do not prove that the implementations agree for every SKU, price, failure, or state transition. They prove the examples they execute. A useful contract grows from caller-visible partitions:
- the lowest and highest valid price;
- invalid or differently normalized SKU values;
- unavailable, discontinued, and missing products if callers distinguish them;
- stable exception categories and any context callers inspect; and
- state changes or ordering guarantees when the port permits writes.
Add a partition because a caller depends on it or a plausible divergence can violate it. Do not copy every implementation test upward merely to make the contract suite look substantial.
Static analysis remains better evidence for method signatures and types. Copy-pasted tests can be sufficient when implementations intentionally serve different callers. A deployed smoke test remains better evidence for live connectivity. The shared suite earns its place in the gap between them: runtime behavior which every substitute claims to provide.
If concrete test cases need different expected values, exception categories, or conditional skips, the shared interface may be hiding different contracts. Splitting the port can be more honest than teaching a contract test to excuse each implementation.
Make the substitute prove the same promise
The mock-fortress experiment kept one catalogue double because remote HTTP was outside the quote risk. That boundary was deliberate, but the double did not become trustworthy by implementing the port.
For each production adapter and fake, list the outputs, failures, and side effects callers rely on. Put the genuinely shared examples in one suite. Then seed a realistic divergence in each implementation and confirm that only its contract run fails while relevant happy paths stay green.
An interface makes substitution possible. The shared contract makes a finite, named part of it measurable. When a new implementation cannot pass that same promise without exceptions in the test, decide whether the implementation is wrong or the port was never truly shared.