Liskov: Substituting a Class

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $8.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Our highly-advanced, proprietary, believability score system is having some performance problems. To help debug it, we want to measure how long calculating a score takes. The simplest way to implement this would be almost entirely inside SightingScorer. We could set a start time on top, then use that down here to calculate a duration. And then we could pass that $duration into the BigFootSightingScore class. Hold Command or Ctrl and click to open it: it's in the src/Model/ directory. Inside here, we could create a new property called $duration... with a getter so that we could use that value.

Lets: Substitute a Class!

But... let me undo that. Let's make things more interesting! To keep our application as skinny as possible on production, I only want to run this new timing code when we're in Symfony's dev environment. And yes, we could inject some $shouldCalculateDuration value into SightingScorer based on the environment and use it to determine if we should do that work.

But, in the spirit of Liskov, instead of changing SightingScorer, I want to create a subclass that does the timing and substitute that class into our system as the SightingScorer service.

It's gonna be kinda fun! And it's a pattern you'll find inside Symfony itself, like with the TraceableEventDispatcher: a class that is substituted in for the real event dispatcher only while developing. It adds debugging info. Well, technically, that class uses decoration instead of being a subclass. That's a different, and usually better design pattern when you want to replace an existing class. But, to really understand Liskov, we'll use a subclass.

Creating the Subclass

Let's start by creating that new subclass. Over in the Service/ directory... so that it's right next to our normal SightingScorer, add a new class called DebuggableSightingScorer. Make it extend the normal SightingScorer.

... lines 1 - 4
class DebuggableSightingScorer extends SightingScorer
{
}

Since our subtype is currently making no changes to the parent class, Liskov would definitely be happy with it. What I mean is: we should definitely be able to substitute this class into our app in place of the original, with no problems.

Substituting the Real Class

But where is the normal SightingScorer service actually used? Open src/Controller/BigFootSightingController.php. This upload() action is the one that is executed when, from the homepage, we click to submit a sighting. Yep, down here, you can see that this is the upload() method.

... lines 1 - 13
class BigFootSightingController extends AbstractController
{
... lines 16 - 19
public function upload(Request $request, SightingScorer $sightingScorer, EntityManagerInterface $entityManager)
{
$form = $this->createForm(BigFootSightingType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
... lines 26 - 40
}
return $this->render('main/sighting_new.html.twig', [
'form' => $form->createView()
]);
}
... lines 47 - 56
}

One of the arguments that's being autowired to this method is the SightingScorer... which is used down here on submit to calculate the score.

Now I want to change this service to use our new class: I want to substitute it. How? Open config/services.yaml. I mentioned earlier that we were going to swap in our DebuggableSightingScorer only in the dev environment. But to keep things simple, I'm actually going to do it in all environments. If you did want to have this only affect your dev environment, you could make the same changes we're about to make in a services_dev.yaml file.

Anyways, to suddenly start using our new class everywhere that the SightingScorer is used, add class: and then App\Service\DebuggableSightingScorer.

... lines 1 - 7
services:
... lines 9 - 32
App\Service\SightingScorer:
class: App\Service\DebuggableSightingScorer
... lines 35 - 37

I know, this looks a little funny. This first line is still the service id. But now instead of using that as the class, Symfony will use DebuggableSightingScorer. The end result is that whenever someone autowires SightingScorer - like we do in our controller - Symfony will instantiate an instance of our DebuggableSightingScorer... and pass the normal $scoringFactors argument. Yep, we just substituted our subclass into the system!

To prove it, find your terminal and run:

php bin/console debug:container Sighting

I want to look at the SightingScorer service, so I'll hit 5. And... perfect! The service id is App\Service\SightingScorer, but the class is App\Service\DebuggableSightingScorer.

Another way to show this would be to go into our BigFootSightingController and temporarily dd($sightingScorer).

Back at your browser, refresh and... there it is! DebuggableSightingScorer

Let's go take that out... then refresh again. The page works and... even though I won't test it, if we submitted, our DebuggableSightingScorer would correctly calculate the believability score.

In other words, no surprise: if you create a subclass and change nothing in it, you can substitute that class for its parent class. It follow's Liskov's principle.

Method Changes that are NOT Allowed

Let's start adding our timing mechanism. In the class, go to Code -> Generate - or Command + N on a Mac - select "Override methods" and override the score() method. If you override a method and keep the same argument type hints and return type, this class is still substitutable: I can refresh and PHP is still happy.

... lines 1 - 4
use App\Entity\BigFootSighting;
use App\Model\BigFootSightingScore;
class DebuggableSightingScorer extends SightingScorer
{
public function score(BigFootSighting $sighting): BigFootSightingScore
{
return parent::score($sighting);
}
}

But if we did change the argument type-hints or return type to something totally different, then even PHP will tell us to knock it off. For example, let's completely change the return type to int.

... lines 1 - 9
public function score(BigFootSighting $sighting): int
{
return parent::score($sighting);
}
... lines 14 - 15

PhpStorm is mad! And if we refresh, PHP is mad too!

DebuggableSightingScorer::score() must be compatible with the parent score(), which returns BigFootSightingScore.

Our signature is incompatible and, nicely, PHP does not let us violate Liskov's principle in this way. Go and undo that change.

So does this mean that we can never change the return type or argument type-hints in a subclass? Actually... no! Remember the rules from earlier: you can change a return type if you make it more narrow, meaning more specific. And you can also change an argument type-hint... as long as you make it accept a wider, or less specific type.

Let's see this in action by finishing our timing feature next.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.4 || ^8.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.1
        "doctrine/annotations": "^1.0", // 1.12.1
        "doctrine/doctrine-bundle": "^2", // 2.3.1
        "doctrine/doctrine-migrations-bundle": "^3", // 3.1.1
        "doctrine/orm": "^2", // 2.8.4
        "knplabs/knp-time-bundle": "^1.15", // v1.16.0
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "sensio/framework-extra-bundle": "^6.0", // v6.1.2
        "symfony/console": "5.2.*", // v5.2.6
        "symfony/dotenv": "5.2.*", // v5.2.4
        "symfony/flex": "^1.9", // v1.12.2
        "symfony/form": "5.2.*", // v5.2.6
        "symfony/framework-bundle": "5.2.*", // v5.2.6
        "symfony/http-client": "5.2.*", // v5.2.6
        "symfony/mailer": "5.2.*", // v5.2.6
        "symfony/property-access": "5.2.*", // v5.2.4
        "symfony/property-info": "5.2.*", // v5.2.4
        "symfony/security-bundle": "5.2.*", // v5.2.6
        "symfony/serializer": "5.2.*", // v5.2.4
        "symfony/twig-bundle": "5.2.*", // v5.2.4
        "symfony/validator": "5.2.*", // v5.2.6
        "symfony/webpack-encore-bundle": "^1.6", // v1.11.1
        "symfony/yaml": "5.2.*", // v5.2.5
        "twig/cssinliner-extra": "^3.3", // v3.3.0
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.0
        "twig/twig": "^2.12|^3.0" // v3.3.0
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.2", // 3.4.0
        "fakerphp/faker": "^1.13", // v1.14.1
        "symfony/debug-bundle": "^5.2", // v5.2.4
        "symfony/maker-bundle": "^1.13", // v1.30.2
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/stopwatch": "^5.2", // v5.2.4
        "symfony/var-dumper": "^5.2", // v5.2.6
        "symfony/web-profiler-bundle": "^5.2" // v5.2.6
    }
}