PHP: Tentative Return Types

PHP 8.1 has introduced tentative return types. This can make older code spit out warnings like mad.
Let’s examine what it means and how to deal with it.

PHP 8.1 Warnings that will become syntax errors by PHP 9

PHP 7.4 to PHP 8.1 have introduced a lot of parameter types and return types to builtin classes that previously did not have types in their signatures. This would make any class extending builtin classes or implementing builtin interface break for the new PHP versions if they did not have the return type specified and would create interesting breaks on older PHP versions.

Remember the Liskov Substitution Principle (LSP): Objects of a parent class can be replaced by objects of the child class. For this to work, several conditions must be met:

  • Return types must be covariant, meaning the same as the parent’s return type or a more specific sub type. If the parent class guarantees to return an iterable then the child class must guarantee an iterable or something more specific, i.e. an ArrayObject or a MyFooList (implements an iterable type).
  • Parameter types must be contravariant, meaning they must allow all parameters the parent would allow, and can possibly allow a wider set of inputs. The child class cannot un-allow anything the parent would accept.
  • Exceptions are often forgotten: Barbara Liskov‘s work implies that Exceptions thrown by a subtype must be the same type as exceptions of the parent type. This allows for child exceptions or wrapping unrelated exceptions into related types.
  • There are some more expectations on the behaviour and semantics of derived classes which usually are ignored by many novice and intermediate programmers and sadly also some senior architects.

Historically, PHP was very lax about any of these requirements. PHP 4 brought classes and some limited inheritance, PHP 5 brought private and protected methods and properties, a new type of constructor and some very limited type system for arrays and classes. PHP 7 and 8 brought union types, intersection types, return type declaration and primitive types (int, string) along with the strict mode. Each version introduced some more constraints on inheritance in the spirit of LSP and gave us the traits feature to keep us from abusing inheritance for language assisted copy/paste. Each version also came with some subtle exceptions from LSP rules to allow backward compatibility, at least for the time being.

In parallel to return types, a lot of internal classes have changed from returning bare PHP resources to actual classes. Library code usually hides these differences and can be upgraded to work with either, depending on which PHP version they run. However, libraries that extend internal classes rather than wrapping them are facing some issues.

PHP’s solution was to make the return type tentative. Extending classes are supposed to declare compatible return types. Incompatible return types are a syntax error just like in a normal user class. Missing return types, no declaration at all, however, are handled more gracefully. Before PHP 8.1, they were silently ignored. Starting in PHP 8.1 they still work as before, but emit a deprecation notice to PHP’s error output, usually a logfile or the systemd journal. Starting in PHP 9 they will be turned into regular syntax errors.

Why is this good?

Adding types to internal classes helps developers use return values more correctly. Modern editors and IDEs like Visual Studio Code or PhpStorm are aware of class signatures and can inform the users about the intended types just as they write the code. Static analysis tools recognize types and signatures as well as some special comments (phpdoc) and can give insight into more subtle edge cases. One such utility is PHPStan. All together they allow us to be more productive, write more robust code with less bugs of the trivial and not so trivial types. This frees us from being super smart on the technical level or hunting down inexplicable, hard to reproduce issues. We can use this saved time and effort to be smarter on the conceptual level: This is where features grow, this is where most performance is usually won and lost.

Why is this bad?

Change is inevitable. Change is usually for the better, even if we don’t see it at first. However, change brings maintenance burden. In the past, Linux distributions often shipped well-tested but old PHP versions to begin with and release cycles, especially in the enterprise environment, were quite long. Developers would have had to write code that would run on the most recent PHP as well as versions released many years ago. Administrators would frown upon developers who always wanted the latest, greatest versions for their silly PHP toys. Real men use Perl anyway. But this has changed a lot. Developers and administrators now coexist peacefully in DevOps teams, CI pipelines bundle OS components, PHP and the latest application code into container images. Containers are bundled into deployments and somebody out there on the internet consumes these bundles with a shell oneliner or a click in some UI and expects a whole zoo of software to start up and cooperate. Things are moving much faster now. The larger the code base you own, the more time you spend on technically boring conversion work. You can be lucky and leverage a lot of external code. The downside is you are now caught in the intersection between PHP’s release cycle and the external code developer’s release cycles – the more vendors the more components that must be kept in sync. PHP 9 is far away but the time window for these technical changes can be more narrow than you think. After all, you have to deliver features and keep up with subtle changes in the behaviour and API of databases, consumed external services, key/value stores and so on. Just keeping a larger piece of software running in a changing and diverse environment is actually hard work. Let’s look at the available options.

How to silence it – Without breaking PHP 5

You can leverage a new attribute introduced in PHP 8.1 – just add it to your code base right above the method. It signals to PHP that it should not emit a notice about the mismatch.

class Horde_Ancient_ArrayType implements ArrayAccess {
     * @return bool PHP 8.1 would require a bool return time 
    public function offsetExists(mixed $offset) {
        // Implementation here

Older PHP that does not know this attribute would just read it as a comment. Hash style comments have been around for long and while most style guides avoid them, they are enabled in all modern PHP versions. This approach will work fine until PHP 9.

How to fix it properly – Be safe for upcoming PHP 9

The obvious way forward is to just change the signature of your extending class.

class Horde_Ancient_ArrayType implements ArrayAccess {
    public function offsetExists(mixed $offset): bool {
        // Implementation here

The change itself is simple enough. If your class is part of a wider type hierarchy, you will need to update all downstream inheriting classes as well. If you like to, you can also reduce checking code on the receiving side that previously guarded against unexpected input or just satisfied your static analyzer.
Tools like rector can help you mastering such tedious upgrade work over a large code base though they require non-trivial time to properly configure them for your specific needs. There are experts out there who can do this for you if you like to hire professional services – but don’t ask me please.

$exists = isset($ancient['element1']);
// No longer necessary - never mind the silly example
if (!is_bool($exists)) {
    throw new Horde_Exception("Some issue or other");

Doing nothing is OK – For now

In many situations, reacting at all is a choice and not doing anything is a sane alternative. As always, it depends. You are planning a major refactoring, replace larger parts of code with a new library or major revision? Your customer has signaled he might move away from the code base? Don’t invest.

My approach for the maintaina-com code base

The maintaina-com github organization holds a fork of the Horde groupware and framework. With over 100 libraries and applications to maintain, it is a good example. While end users likely won’t see the difference, the code base is adapted for modern PHP versions, more recent major versions of external libraries, databases, composer as an installer and autoloader. Newer bits of code support the PHP-FIG standards from PSR-3 Logging to PSR-18 HTTP Client. Older pieces show their age in design and implementation. Exactly the amount of change described above makes it hard to merge back changes into the official horde builds – this is an ongoing effort. Changes from upstream horde are integrated as soon as possible.

I approach signature upgrades and other such tasks by grouping code in three categories:

  • Traditional code lives in /lib and follows a coding convention largely founded on PHP 5.x idioms, PSR-0 autoloading, PSR-1/PSR-2 guidelines with some exceptions. This code is mostly unnamespaced, some of it traces back into PHP 4 times. Coverage with unit tests is mostly good for libraries and lacking for applications. Some of this is just wrapping more modern implementations for consumption by older code, hiding incompatible improvements. This is where I adopt attributes when upstream does or when I happen to touch code but I make no active effort.
  • More modern code in /src follows PSR-4 autoloading, namespaces, PSR-12 coding standards, modern signatures and features to an increasing degree. This generally MUST run on PHP 7.4 and SHOULD run on recent PHP releases. This is where I actively pursue forward compatibility. Unit tests usually get a facelift to these standards and PHPStan coverage in a systematic fashion.
  • Glue code, utility code and interfaces are touched in a pragmatic fashion. Major rewrites come with updated standards and approaches, minor updates mostly ensure compatibility with the ever changing ecosystem.

If you maintain a large code base, you are likely know your own tradeoffs, the efforts you keep postponing in favour of more interesting or more urgent work until you have to. Your strategy might be different, porting everything to a certain baseline standard before approaching the next angle maybe. There is no right or wrong as long as it works for you.

Leave a Reply

Your email address will not be published.