Watch your inheritance

Larry Garfield
Documentation Director
13 Dec 2017

PHP 7.2 introduced a neat new feature called "type widening". In short, it allows methods that inherit from a parent class or interface to be more liberal in what they accept (parameters) and more strict in what they return (return values) than their parent. In practice they can only do so by removing a type hint (for parameters) or adding one where one didn't exist before (return values), not for subclasses of a parameter. (The reasons for that are largely implementation details far too nerdy for us to go into here.) Still, it's a nice enhancement and in many ways makes PHP 7.2 more compatible with earlier, less-typed versions of PHP than 7.0 or 7.1 were.

There's a catch, though: Because the PHP engine is paying more attention to parameter types than it used to, it means it's now rejecting more invalid uses than it used to. That's historically one of the main sources of incompatibilities between different PHP versions: Code that was technically wrong but the engine didn't care stops working when the engine starts caring in new version. Type widening is PHP 7.2's case of that change.

Consider this code:

interface StuffDoer {
  public function doStuff();
}

class A implements StuffDoer { public function doStuff(StuffDoer $x = null) {} }

This is nominally valid, since A allows zero parameters in doStuff(), which is thus compatible with the StuffDoer interface.

Now consider this code:

class A {
  public function doStuff(StuffDoer $x = null) {}
}

class B extends A { public function doStuff() {} }

While it seems at first like it makes sense, it's still invalid. We know that B is going to not do anything with the optional $x parameter, so let's not bother defining it. While that intuitively seems logical the PHP engine disagrees and insists on the parameter being defined in the child class, even though you and I know it will never be used. The reason is that another child of B, say C, could try to re-add another optional parameter of another type; that would technically be compatible with B, but could never be compatible with A. So, yeah, let's not do that.

But what happens if you combine them?

interface StuffDoer {
  public function doStuff();
}

class A implements StuffDoer { public function doStuff(StuffDoer $x = null) {} }

class B extends A { public function doStuff() {} }

There's two possible ways to think about this code.

  1. B::doStuff() implements StuffDoer::doStuff(), which has no parameters, so everything is fine.
  2. B::doStuff() extends A::doStuff(), which has a parameter. You can't leave off a parameter, so that is not cool.

Prior to PHP 7.2, the engine implicitly went with interpretation 1. The code ran fine. As of PHP 7.2.0, the engine now uses interpretation 2. It has to, because it's now being more careful about when you're allowed to drop a type on a parameter in order to support type widening. So this wrong-but-working code now causes a fatal error. Oopsies.

Fortunately, the quickfix is super easy: Just be explicit with the parameter, even if you know you're not going to be using it:

interface StuffDoer {
  public function doStuff();
}

class A implements StuffDoer { public function doStuff(StuffDoer $x = null) {} }

class B extends A { public function doStuff(StuffDoer $x = null) {} }

The more robust fix is conceptually simpler: Don't do that. While adding optional parameters to a method technically doesn't violate the letter of an interface, it does violate the spirit of the interface. The method is now behaving differently, at least sometimes, and so is not a true drop-in implementation of the interface.

If you find your code is doing that sort of stealth interface extension, it's probably time to think about refactoring it. As a stopgap, though, you should be able to just be more explicit about the parameters in child classes to work around the fatal error.

Enjoy your PHP 7.2!