loading...

How to deprecate a type in php

greg0ire profile image Grégoire Paris Updated on ・3 min read

So you have this class or interface you want to rename to something else, because you need to move that type to another package, or you have new coding standard rules that need to be applied to its name. One solution is to use inheritance and move all the code to the new type:

<?php

// LegacyFoo.php

/**
 * @deprecated please use ShinyNewFoo instead!
 */
interface LegacyFoo extends ShinyNewFoo
{
}

This approach is great until your users write code that expects the old type, and gets the new type from your code instead:

<?php

class Bar // registered and invoked by your library
{
    public function __invoke(LegacyFoo $foo) // crash
    {
    }
}

But fear not, there is another approach, that consists in creating an alias for your type.

<?php

// LegacyFoo.php

class_alias(ShinyNewFoo::class, LegacyFoo::class);

This creates an alias from interface LegacyFoo, to interface ShinyNewFoo. You read that right, although it is class_alias and I used the class constant, both work for interfaces. But this is not enough, because nothing guarantees LegacyFoo will be autoloaded at some point. Using that type in a parameter type declaration does not trigger autoload, because surely the type should be autoloaded when the object passed as parameter is instantiated. Well, this optimization does not work for type aliases, which means we have to manually trigger the autoload at the bottom of ShinyNewFoo.php.

<?php

// ShinyNewFoo.php

class_exists(LegacyFoo::class);

Think this is over? Not so fast, there is more. Now that it all works, let us put this in production, and see it burst into flames! class_alias calls are so rarely used that Composer does not look for them when generating its autoloader classmap. Autoload classmaps are a way to know which files to load without checking the filesystem first, which is faster. To trick Composer into detecting the legacy type, we can declare it as a piece of dead code:

<?php

// LegacyFoo.php

class_alias(ShinyNewFoo::class, LegacyFoo::class);

if (false) {
    class LegacyFoo extends ShinyNewFoo
    {
    }
}

This will also fool most IDEs into providing auto-complete.

And finally, what if we want to trigger a deprecation error when the legacy type is used? We cannot just do it since it will be autoloaded every time we use the new type. All we can do is implement a best-effort solution:

<?php

if (!class_exists(ShinyNewFoo::class, false)) {
    @trigger_error(
        'LegacyFoo is deprecated!',
        E_USER_DEPRECATED
    );
}

This checks first if ShinyNewFoo exists, without triggering autoload. If it does not, then LegacyFoo is referenced somewhere and we can safely trigger a deprecation.

Done? The following seems to have been fixed with recent versions of php, you can ignore it if your library requires php >= 7.4.0.

Otherwise… nope, not done, you sweet summer child! It goes deeper.

This is an even rarer occurrence, but let us consider an interface that you expose, and that used the deprecated type in one of its signature and was switched to the new type:

<?php

interface Bar
{
    public function baz(ShinyNewFoo $foo);
}

What should happen to the implementation of your consumers?

Let us also consider that class that does something similar, but that you did not mark as final… (or that could be abstract, same issue).

<?php

abstract class Foobar
{
    abstract public function foobar(ShinyNewFoo $foo);
}

What should happen to extending classes of your consumers?

Well they shall crash and burn, of course! Since type declarations do not trigger autoload, the alias does not exist, so PHP cannot know both type declarations mean the same thing.

<?php

final class ExtendingFoobar extends Foobar implements Bar
{
    public function baz(LegacyFoo $foo) // 💥
    {
    }

    public function foobar(LegacyFoo $foo) // 💥
    {
    }
}

Unless… you guessed it, we need to add yet another call to class_exists (or interface_exists) call to trigger the autoload. In order not to get a deprecation, we will use that on the new type, and it will in turn silently load the old type and do the class alias.

<?php

interface Bar
{
    public function baz(ShinyNewFoo $foo);
}
class_exists(\ShinyNewFoo::class);

To sum things up, every extensible interface, every interface that uses the old type in one of its signatures should make sure to autoload the new type.

Done. Until next time. Wow that was hard, and I cannot say it feels very satisfying. I wish there were a native way in php to do all this.

If you want to tinker with this problem yourself and try things out, here is a repo that might serve as a good starting point for you: https://github.com/greg0ire/type_deprecation_experiment

Discussion

pic
Editor guide
 

It seems that you're trying to create a technical solution for a non-technical issue. Use semantic versioning and annotate the type deprecated in next minor version. Remove type in next major version.

 

How lucky you are. You've never had the pleasure of dealing with huge legacy applications ;)
An application so outdated that SemVer did not exist.

 

This will not be enough, sadly. I need to make the new type usable everywhere the legacy type is.

 

I'm with Aleksi on this one. Semantic version out the old name, then remove in following version release. Any logic that has to be carried over from the old to the new should be moved and tests created to validate functionality.

    1.0.0 = Added SomeClass
    2.0.0 = SomeClass deprecated, use NewClass
    3.0.0 = SomeClass removed use NewClass

As a production system maintainer I always lock my dependencies to major release version. (If a library does not use SemVer, I don't use that package.)

Not sure I understand. Please show me how you would implement a Foo class that would pass the following tests:
github.com/greg0ire/type_deprecati...

It's not an implementation issue imo. This is from Semantic versioning homepage:

How should I handle deprecating functionality?

Deprecating existing functionality is a normal part of software development and is often required to make forward progress. When you deprecate part of your public API, you should do two things: (1) update your documentation to let users know about the change, (2) issue a new minor release with the deprecation in place. Before you completely remove the functionality in a new major release there should be at least one minor release that contains the deprecation so that users can smoothly transition to the new API.

Maybe that was unclear (I wrote this in a rush), but this blog post is about how you can implement that part of SemVer (deprecating part of your public API) for php types. Not sure what "It" is referring to in "It's not an implementation issue IMO" or why you two attempt to teach me the basics of SemVer when I'm trying to show how to put it in practice for a specific part of a PHP API.

Ah, with "it" i mean deprecating a type in php. As far as i'm concerned this is enough:

<?php

// LegacyFoo.php

/**
 * @deprecated please use ShinyNewFoo instead!
 */
interface LegacyFoo
{
    //...
}

But i also accept the fact that i may have missed some point in this article...

This solution is enough in some situations. In others, you will need inheritance, and in some more complicated situations, you will have to resort to class aliases. That's what I failed to make perfectly clear in my article.

 

I should do a lot of job for make the class deprecated. A modern IDE helps you to check deprecated class or not using annotation @deprecated. Its always help me. Maybe your approach have a good solution for this case, but your example is not from real life I think

 

Interesting post ! Sadly I don't think it is possible to trigger a deprecation in a straightforward way in the case you present. Furthermore I'm not sure this chunk of code will work in all cases:

<?php

if (!class_exists(ShinyNewFoo::class, false)) {
    @trigger_error(
        'LegacyFoo is deprecated!',
        E_USER_DEPRECATED
    );
}

If ShinyNewFoo is used in your user's code before LegacyFoo, I'm not sure that the deprecation will be triggered.

 

You're correct, that's why I labelled this a best effort, it's really not ideal, but I'm afraid it's all we have.

 

Is there a GH issue for composer workaround? Composer should fix this instead of encouraging ecosystem to keep on doing this hack.