Index

Substitution Is a Behavioral Contract

Both of these classes implement the same interface:

interface InvoiceArchive
{
    public function store(InvoiceId $id, PdfDocument $document): Location;
    public function read(Location $location): PdfDocument;
}
final class NullInvoiceArchive implements InvoiceArchive
{
    public function store(InvoiceId $id, PdfDocument $document): Location
    {
        return Location::from("memory://{$id->value}.pdf");
    }

    public function read(Location $location): PdfDocument
    {
        throw new RuntimeException('Nothing was stored.');
    }
}

PHP accepts the implementation. A client that stores a document and later reads the returned location does not. The method signatures match while the behavioral contract is broken.

The Liskov Substitution Principle is about that gap. Code written against a supertype must continue to work when it receives any subtype. Interfaces, inheritance, fakes, decorators, and adapters all create subtype promises; the principle is not limited to class hierarchies.

Interface segregation decides which operations a client should see. Substitution asks whether every implementation keeps the behavioral promises behind those operations.

Start with what clients are allowed to assume

The archive contract needs more than PHP types:

store
  accepts any valid invoice ID and PDF document
  returns a location owned by this archive
  makes the same document available through read(location)

read
  returns the document previously stored at the location
  throws InvoiceDocumentNotFound when no document exists

Now the NullInvoiceArchive failure is obvious. It returns a location while refusing the postcondition that makes the location useful. A no-op implementation is valid only if the contract permits discarded writes and does not promise later reads. Naming a class Null cannot weaken the clients' expectations.

Liskov and Wing’s paper, A Behavioral Notion of Subtyping, frames subtyping around preserving supertype method behavior, invariants, and history properties. The practical consequence is that a subtype must keep every promise clients can prove from the parent contract.

Do not demand more from the caller

Suppose ShippingQuotes accepts every complete domestic shipment:

interface ShippingQuotes
{
    /** @throws ShippingServiceUnavailable */
    public function for(Shipment $shipment): ShippingQuote;
}

This adapter strengthens the precondition invisibly:

final class ParcelLockerQuotes implements ShippingQuotes
{
    public function for(Shipment $shipment): ShippingQuote
    {
        if ($shipment->weight()->grams > 5_000) {
            throw new InvalidArgumentException('Maximum weight is 5 kg.');
        }

        // ...
    }
}

A caller allowed to submit a 10 kg domestic shipment cannot substitute this implementation. The design has two honest options:

  1. narrow the common contract so every implementation supports the declared shipment set; or
  2. make capability selection explicit before calling a narrower interface.
interface ShippingService
{
    public function supports(Shipment $shipment): bool;
    public function quote(Shipment $shipment): ShippingQuote;
}

Even then, quote() must define what happens if a caller ignores supports(). Keep supports() limited to stable eligibility such as weight, route, or configured service capability. It should not make a live provider request that can become stale before quote() runs. Dynamic capacity and provider failure remain declared outcomes of quote(), even for an immutable shipment.

Do not promise less in the result

A subtype weakens a postcondition when it returns less reliable output:

final class CachedExchangeRates implements ExchangeRates
{
    public function rate(Currency $from, Currency $to): Rate
    {
        return $this->cache->remember(
            "{$from->value}:{$to->value}",
            now()->addDay(),
            fn () => $this->inner->rate($from, $to),
        );
    }
}

If ExchangeRates promises data no older than five minutes, a one-day cache violates the contract even though its Rate type is correct. A decorator must preserve freshness, error, and identity semantics as well as return types.

The same applies to pagination totals, monetary currency, sort order, transaction isolation, and idempotency. Static analysis can verify some type variance; it cannot infer most operational promises.

Exceptions are part of observable behavior

An application contract may normalize provider failures:

interface Payments
{
    /**
     * @throws PaymentDeclined
     * @throws PaymentsUnavailable
     */
    public function capture(PaymentRequest $request): Payment;
}

An adapter that leaks GuzzleException, a vendor SDK exception, or a JSON parser error forces clients to know which implementation they received. It adds exception behavior not declared by the abstraction.

try {
    return $this->captureWithProvider($request);
} catch (VendorCardDeclined $exception) {
    throw PaymentDeclined::from($exception);
} catch (VendorTransportFailure $exception) {
    throw PaymentsUnavailable::from($exception);
}

Do not translate every programming defect into PaymentsUnavailable. Contract exceptions describe expected failure categories; a type error or broken response mapper should still fail visibly.

History constraints catch mutable subtype problems

Consider a base account that only permits balance changes through recorded transactions. A subtype that adds setBalance() can create states impossible through the base contract:

final class ImportedAccount extends Account
{
    public function setBalance(Money $balance): void
    {
        $this->balance = $balance;
    }
}

Even if base methods behave correctly in isolation, the subtype’s new mutator breaks the history property that every balance change has a ledger entry.

This is why inheritance based on shared fields is dangerous. A subtype inherits all client expectations attached to the parent, not only protected code. If the child cannot preserve the parent’s invariants across its entire lifecycle, composition is the more honest relationship.

Test doubles must be substitutable too

This fake payment gateway makes tests easy and misleading:

final class NonIdempotentFakePayments implements Payments
{
    public function capture(PaymentRequest $request): Payment
    {
        return Payment::captured(PaymentId::new(), $request->amount);
    }
}

If the real contract promises idempotency by request key, calling the fake twice must return the same payment rather than create two identities. Otherwise application tests can pass while retry behavior fails in production.

Run one behavioral contract against every implementation:

function paymentsContract(string $implementation, Closure $payments): void
{
    describe($implementation, function () use ($payments) {
        it('is idempotent for the same request key', function () use ($payments) {
            $gateway = $payments();
            $request = aPaymentRequest(key: 'payment-attempt-1');

            $first = $gateway->capture($request);
            $second = $gateway->capture($request);

            expect($second->id)->toEqual($first->id);
        });

        it('preserves the requested currency', function () use ($payments) {
            $payment = $payments()->capture(
                aPaymentRequest(amount: Money::EUR(10_00)),
            );

            expect($payment->amount->currency)->toBe(Currency::EUR);
        });
    });
}

paymentsContract('provider adapter', fn () => providerPayments());
paymentsContract('in-memory fake', fn () => idempotentFakePayments());

Pest’s describe blocks keep the shared assertions distinct for each implementation. Run the suite once against the NonIdempotentFakePayments example as a negative control: the idempotency assertion must fail. A shared suite that cannot reject a known violation has not proved the promise.

The real adapter still needs provider integration tests. The shared contract checks that real, fake, cached, and decorated implementations preserve the same client-visible behavior; it does not verify provider transport or credentials.

During an adapter migration, passing the contract is the minimum comparison. Replay representative, non-destructive inputs through old and new adapters and compare normalized results and failures. A difference either exposes a missing contract promise or blocks the cutover. For side-effecting operations such as payment capture, compare recorded responses or provider sandboxes rather than issuing the operation twice.

Mocks deserve the same scrutiny. A mock configured to return impossible data teaches the test a contract no implementation should satisfy. Prefer small fakes or builders that enforce the same invariants as production values.

Conditional client code is substitution evidence

This branch admits that the common type cannot be trusted:

if ($archive instanceof S3InvoiceArchive) {
    $location = $archive->store($id, $pdf);
} else {
    $archive->store($id, $pdf);
    $location = null;
}

Sometimes implementation-specific behavior is legitimate. Expose it as a separate capability instead of branching on concrete classes:

interface StoresInvoices
{
    public function store(InvoiceId $id, PdfDocument $document): void;
}

interface LocatesInvoices extends StoresInvoices
{
    public function locationOf(InvoiceId $id): Location;
}

Now clients choose the promise they need. The narrower interface no longer pretends every store produces a readable location.

Define contracts before adding implementations

For each abstraction, write down:

  • accepted inputs and preconditions;
  • successful result guarantees;
  • invariants that remain true;
  • expected exceptions and failure categories;
  • ordering, freshness, and idempotency promises; and
  • side effects visible after each operation.

Then verify every implementation, including fakes and decorators, against those behaviors. When one implementation cannot comply, change the hierarchy or split the capability. Do not add client conditionals that make the type system’s promise optional.

LSP is satisfied when callers can reason from the abstraction and remain correct without discovering the concrete class. Matching signatures open the door. Preserving behavior is what makes substitution safe.