Refactoring with Deprecations

Deprecating old code and replacing it with new and improved APIs is an established process in software development. In the core of PHP APIs are provided to trigger and to get notified of deprecations.

  1. If a feature in PHP is deprecated, the language triggers a notification message with the E_DEPRECATED level.
  2. As a developer, library or framework author, deprecations can be triggered directly from PHP code using the function trigger_error() with the E_DEPRECATED or E_USER_DEPRECATED levels.

As a PHP application developer you can then hook into all triggered deprecations using a user defined error handler. You can use this API to collect deprecations and fix them.

A Refactoring Workflow using Deprecations

A common workflow to slowly and safely improve your code, without potentially breaking usages of your old code is a strategy of introducing new APIs and deprecating the old ones. Both APIs are then maintained until all usages of the old API is replaced with the new API.

A checklist of this workflow looks as follows:

  1. Introduce a new API that improves upon the old API.
  2. Add a call to trigger_error in the old API (unless its called by the new API)
  3. Roll out this change to production and start logging the deprecations with Tideways.
  4. Replace ocurring deprecations where you haven’t replaced the old with the new API
  5. Remove the old API as soon as you are confident the old API is not used anymore, potentially by not seeing the deprecation message anymore for several days.

If you can replace the old API with the new one in a single step, then you don’t need the indirection through using deprecations. But when an API is used wide spread throughout the code base this approach allows you to do a gradual migration instead of a big bang refactoring that introduces the risk of bugs.

Example: Deprecating an API

Lets see an example. We have a method that allows querying from a database table using many parameters to influence the query conditions, the example is directly taken from Tideways own code-basis:

<?php

interface TracepointRuleRepository
{
    /**
     * @return array<TracepointRule>
     */
    public function findAllTracepointRules(
        int $applicationId,
        bool $activeOnly = false,
        string $service = '',
        string $environment = '') : array;
}

We have recently added two new parameters to this API for $service and $environment. Because many optional parameters are a code smell, we decided to refactor this code to what we call the “Criteria API Pattern” in our team. More generally the pattern is called Parameter Object.

First we introduce the Criteria object:

<?php

class TracepointCriteria extends Struct
{
    public bool $activeOnly = false;
    public string $service = '';
    public string $environment = '';
}

Then we introduce a new method on the interface and the coresponding implementation:

<?php

interface TracepointRuleRepository
{
    /**
     * @return array<TracepointRule>
     */
    public function findMatching(int $applicationId, TracepointCriteria $criteria) : array;
}

The important part is not to change the original old API and keep it around. Instead we modify this code to trigger a deprecation:

<?php

class TracepointRuleOrmRepository implements TracepointRuleRepository
{
    /**
     * @return array<TracepointRule>
     */
    public function findAllTracepointRules(
        int $applicationId,
        bool $activeOnly = false,
        string $service = '',
        string $environment = '') : array
    {
        @trigger_error("findAllTracepointRules() is deprecated, use findByCriteria() instead.", E_USER_DEPRECATED);

        // original implementation
    }
}

After enabling deprecations tracking in Tideways, these deprecations now look like in the following screenshot:

A deprecation in Tideways

You can see the stacktrace to this deprecation and if you start fixing occurrences, until the deprecation is not triggered again.

For the new implementation of the findMatching method there are now three approaches we can take:

  1. Completly implement the new method from scratch and keep the old method.
  2. Implement the new method from scratch, but call the newly implement method from the old, deprecated method.
  3. Call the deprecated code from the new method. This is the most tricky, because we have to avoid that the deprecation is being triggered. We can do this by passing a parameter that controls the behavior.
<?php

class TracepointRuleOrmRepository implements TracepointRuleRepository
{
    /**
     * @return array<TracepointRule>
     */
    public function findAllTracepointRules(
        int $applicationId,
        bool $activeOnly = false,
        string $service = '',
        string $environment = '',
        bool $triggerDeprecation = true) : array
    {
        if ($triggerDeprecation) {
            @trigger_error("findAllTracepointRules() is deprecated, use findByCriteria() instead.", E_USER_DEPRECATED);
        }

        // original implementation
    }

    public function findMatching(int $applicationId, TracepointCriteria $criteria) : array
    {
        return $this->findAllTracepointRules(
            $applicationId,
            $criteria->activeOnly,
            $criteria->service,
            $criteria->environment,
            false
        );
    }
}
Benjamin Benjamin 25.05.2022