The Liskov Substitution Principle is one of the five design principles that make for SOLID code, and probably one of the hardest to fully grasp.
Before we dive into how we can apply it to our code, let’s take a look at the definition:

In a computer program, if S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program (correctness, task performed, etc.)

Or in other words, any code calling methods on objects of a specific type should continue to work when those objects get replaced with instances of a subtype.

What is a subtype exactly?

In most object-oriented languages a subtype can either be a class extending another class, or a class implementing an interface.

Let’s look at an example in PHP.

interface Animal {}
class Cat implements Animal {}
class MaineCoon extends Cat {}

// A class implementing an interface is considered to be a subclass of said interface.
is_subclass_of(Cat::class, Animal::class); // bool(true)

// A class extending another class is a subclass of said parent class.
is_subclass_of(MaineCoon::class, Cat::class); // bool(true)

// By extension, the child class is also considered to be a subclass of the interfaces 
// implemented by its parent class.
is_subclass_of(MaineCoon::class, Animal::class); // bool(true)

So even if you never use inheritance, you’re technically still dealing with subtypes and thus the Liskov Substitution Principle whenever you implement an interface.

How can we make sure that we’re not changing the intended usage of the original type?

As defined in the open-closed principle (the “O” in SOLID), classes and especially interfaces should be open for extension but closed for modification. So simply closing every type off from extension is not possible.

Instead, we can enforce some requirements so that the original type’s usage cannot be altered.

1. Method arguments of the subtype should be contravariant.

Let’s say we have an CatShelter can take in Cat

interface CatShelter 
{
    public function takeIn(Cat $cat): void;
}

When we implement this interface, we have to make sure that any code calling takeIn() with Cat works. However, because method arguments of a subtype are contravariant, we can widen the argument types.

class MixedShelter implements CatShelter 
{
    public function takeIn(Animal $animal): void 
    {
        // This is still a valid implementation, because we comply with the interface's 
        // contract stating that takeIn() should always accept a Cat as the first method 
        // argument. By accepting any Animal instead we have not violated that 
        // requirement.
    }
}

Method arguments being contravariant not only means that we can widen their types, but also that we cannot make their types more narrow.

class MaineCoonShelter implements CatShelter 
{
    public function takeIn(MaineCoon $maineCoon): void 
    {
        // This is NOT a valid implementation, because by narrowing the method
        // argument's type to MaineCoon we have violated the contract defined in 
        // CatShelter that we have to allow any Cat.
    }
}

For interface implementations, this is usually pretty clear because we are used to code depending on the interface and not the implementation. But the same also goes for classes extending another class, which is when developers sometimes forget that they still have to honour the contract of the original class for the Liskov Substitution Principle to remain true.

2. Method return types of the subtype should be covariant.

The opposite of contravariance, return types being covariant means that you can make them more narrow in subtypes, but not wider.

In the case of CatShelter, this can be illustrated as follows.

interface CatShelter 
{
    public function getCatForAdoption(): Cat;
}

class MaineCoonShelter implements CatShelter
{
    public function getCatForAdoption(): MaineCoon
    {
        // Even though we have narrowed the return type to MaineCoon instead of Cat, 
        // we are still honoring the CatShelter contract because technically we always 
        // return a Cat as required.
    }
}
class MixedShelter implements CatShelter
{
    public function getCatForAdoption(): Animal
    {
        // This is NOT a valid implementation, because by widening the return type
        // we can no longer guarantee that the returned instance will be a Cat and
        // the code calling this method might not know how to handle another kind
        // of Animal.
    }
}

3. No new exceptions should be thrown by methods of the subtype, except where those exceptions are themselves subtypes of exceptions thrown by the methods of the supertype.

I agree with this rule in theory, but it matters a lot less in our actual applications than having contravariant method arguments and covariant return types.

The idea is that if a subtype starts throwing exceptions that the original type would never throw, the code using that subtype would not know how to handle that exception if it only expects the exceptions of the original type.

However, in a lot of cases, it might not even have a sensible way to handle those exceptions anyway, and the exception would be caught by the global error handler configured in your application.

Implications for the self and static keywords in PHP

Because self and static are aliases for the class name in which they are respectively defined or called, we can do some cool things with them combined with method argument contravariance and return type covariance.

A recent example we had in a project at madewithlove is a Metadata interface with a method that should return an instance of self.

interface CustomFieldMetadata
{
    // Note that the "self" return type resolves to 
    // "CustomFieldMetadata" here.
    public static function fromArray(array $metadata): self; 
}

class FreeTextFieldMetadata implements CustomFieldMetadata
{    
    // Note that "static" resolves to "FreeTextFieldMetadata" here
    // because it always resolves to the class on which the method is called.
    public static function fromArray(array $metadata): static 
    {
        return new static(...);
    }
}

class ExtendedFreeTextFieldMetadata extends FreeTextFieldMetadata
{    
    // This class is just an example for illustrative purposes, but if it did exist 
    // calling the ExtendedFreeTextFieldMetadata::fromArray() method - even 
    // without overriding it - would always have an ExtendedFreeTextFieldMetadata 
    // return type and the static code analyzer in your IDE or tools like PHPStan
    // would also recognize this change.
}

Even though we are essentially changing the return type on the fly based on the class we’re using, the implementations are still valid because return types are allowed to be more narrow in subtypes.

In fact, you cannot static a return type in interface method definitions.

interface CustomFieldMetadata
{
    public static function fromArray(array $metadata): static; 
}

Parse error:  syntax error, unexpected 'static' (T_STATIC) in [...][...] on line ...

This is because the code depending on this interface should not care about the concrete implementation being returned by the fromArray() method. It should only care that it returns an instance of the CustomFieldMetadata interface.

So we can only use self or the literal interface name (CustomFieldMetadata) as the return type for methods like this. But thanks to return type covariance we can make the return type narrower in the actual implementation.

The constructor caveat

PHP wouldn’t be PHP if it didn’t have some special behaviour not present in other object-oriented languages.

One of these quirks is that while method arguments in subtypes should always be contravariant, the arguments in __construct() methods of subtypes are not enforced to be contravariant.

Let’s take another look at a recent example from another project.

abstract class CustomFieldDefinition
{
    public function __construct(AccountId $accountId) { ... }
}

// Example of a Liskov Substitution Principle violation but allowed by PHP:
final class MultipleChoiceFieldDefinition extends CustomFieldDefinition
{
    public function __construct(
        AccountId $accountId,
        array $choices
    ) {
        // By adding an extra required argument, we just broke the method calls 
        // of any code trying to construct instances of CustomFieldDefinition.
    }
}

Because we had to be able to instantiate CustomFieldDefinition instances of different types dynamically in a single place (i.e. the class name being a variable), we had to make it impossible to violate the Liskov Substitution Principle like this.

So to prevent this from becoming an issue, we closed off __construct from being modified by making final

abstract class CustomFieldDefinition
{
    final public function __construct(AccountId $accountId) { ... }
}

final class MultipleChoiceFieldDefinition extends CustomFieldDefinition
{
    public function __construct(
        AccountId $accountId,
        array $choices
    ) {
        // Overriding the __construct method would instantly result in an error.
        // Fatal error:  Cannot override final method CustomFieldDefinition::__construct()
    }
}

Another alternative would be making it private and having a static create(...): CustomFieldDefinition method CustomFieldDefinition.

abstract class CustomFieldDefinition
{
    private function __construct(AccountId $accountId) { ... }

    public static function create(
        AccountId $accountId, 
    ): CustomFieldDefinition {
        // We can still use the constructor here because we have access to any private
        // methods and properties defined in this class, including __construct().
        return new static($accountId, $variableName);
    }
}

However, you’d only get a warning instead of a fatal error when overriding this create()method:

Warning: Declaration of MultipleChoiceFieldDefinition::create(AccountId $accountId, array $choices): CustomFieldDefinition

should be compatible with CustomFieldDefinition::create(AccountId $accountId)

So making the __construct() method final seems to be the best solution to prevent violations of the Liskov Substitution Principle wherever possible (and necessary).

TL;DR

Applying the Liskov Substitution Principle in PHP means that:

  • You can widen the types of method arguments in child classes and interface implementations, but not make them more narrow.
  • You can make the return types in child classes and interface implementations more narrow, but not widen them.
  • In subtypes, you should try to avoid throwing exceptions that are not thrown by the parent class or documented on the interface being implemented (where possible).
  • You should consider closing off your constructor method arguments from modification in subtypes by making final

Shout-out to Barbara Liskov and Jeanette Wing for introducing this principle to the world and making our software more type-safe.