Skip to main content

phpstan-drupal 0.12.15: Improved detection of deprecated service usage

Published on

I have some exciting news to share for phpstan-drupal! With the 0.12.15 release, the ability to detect and report usages of deprecated services should have full coverage! Previously, phpstan-drupal only detected if you used a method on a deprecated service. And that happened to be if the service was fetched from an object implementing ContainerInterface. That meant calls to \Drupal::service were left uncovered – even though it calls self::getContainer->get() (I thought it would "just work," but it did not.)

Palantir.net renewed for more sponsored live development streams, this time with a focus on the Upgrade Status module (and in turn the phpstan-drupal project.) This has made a huge impact in just a few streams in the capabilities of phpstan-drupal.

Before 0.12.15

What does all of that mean? Here is a code example taken from the phpstan-drupal test fixtures to explain. I've added inline code comments, but I'll go into detail afterward.

<?php

class TestServicesMappingExtension {
    public function testEntityManager() {
        // This line wouldn't error.
        $entity_manager = \Drupal::getContainer()->get('entity.manager');
        // But it would know that the method didn't exist.
        $doesNotExist = $entity_manager->thisMethodDoesNotExist();
        // Would error about a deprecated method.
        $definitions = $entity_manager->getDefinitions();
    }

    public function testPathAliasManagerServiceRename() {
        // The service is not detected properly and PHPStan skipped analysis.
        $manager = \Drupal::service('path.alias_manager');
        $path = $manager->getPathByAlias('/foo/bar', 'en');
    }
}

PHPStan has a concept of dynamic return type extensions. Return type extensions allow you to help PHPStan under what kind of object to return. When it comes to services in the service container, it could be anything. The ContainerDynamicReturnTypeExtension in phpstan-drupal utilized a service mapping to return the correct class when fetching a service from the container. Unfortunately, it did not work with \Drupal::service. It also didn't report errors that the service was deprecated. It only reported a deprecation if that service had a method called. That means the constructor may have injected deprecated services without warning.

If PHPStan cannot determine the return type like mixed, then no analysis can take place to find deprecated usages. Guess what \Drupal::service has as its return type? It has @return mixed for its return type in its document comment block.

Fixing the return type for \Drupal::service would still leave one problem: flagging the service name argument if it is deprecated. This required a new rule to inspect the service name passed to ContainerInterface::get and \Drupal::service to report when your code calls a deprecated service.

Oh, and figuring out how to know if the service was deprecated or not.

That led to the following two issues and their fixes:

 

Detect when deprecated services are fetched from the container

One of the first items I worked to fix was catching calls to the container for a deprecated service when constructing an object. This can happen when a class has ContainerInjectionInterfaceContainerFactoryPluginInterface, or any of the other "I want to get the container injected and construct myself" interfaces in Drupal. 

Here is an example of a controller injecting the deprecated entity.manager service

class EntityManagerInjectedController implements ContainerInjectionInterface {

    protected $entityManager;

    public function __construct(EntityManagerInterface $entity_manager)
    {
        $this->entityManager = $entity_manager;
    }

    public static function create(ContainerInterface $container)
    {
        return new self(
            $container->get('entity.manager')
        );
    }
}

And an example of a block injecting the entity.manager service.

class EntityManagerInjectedBlock extends BlockBase implements ContainerFactoryPluginInterface {

    /**
     * @var EntityManagerInterface
     */
    protected $entityManager;

    public function __construct($configuration, $plugin_id, $plugin_definition, EntityManagerInterface $entity_manager) {
        parent::__construct($configuration, $plugin_id, $plugin_definition);
        $this->entityManager = $entity_manager;
    }

    /**
     * {@inheritdoc}
     */
    public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
        return new static(
            $configuration,
            $plugin_id,
            $plugin_definition,
            $container->get('entity.manager')
        );
    }
}

Previously these were skipped over when PHPStan analyzed Drupal code. That's because a rule didn't exist to inspect the parameters and check if the value provided was a deprecated service.

In phpstan-drupal, I needed to update our service mapping to respect the deprecated flags allowed in Symfony's dependency injection. Once this was added, I was able to write the GetDeprecatedServiceRule rule which checked the argument passed to ContainerInterface::get, look up the service, and report an error if it was marked as deprecated!

        $service = $this->serviceMap->getService($serviceName->value);
        if (($service instanceof DrupalServiceDefinition) && $service->isDeprecated()) {
            return [$service->getDeprecatedDescription()];
        }

Here's the recording from the live stream where I resolved this issue.

Dynamic return type extension for \Drupal::service

Creating a dynamic return type extension for \Drupal::service helped fix the following issues in the Upgrade Status module and would have probably saved a lot of folks from various curse words during their Drupal 9 upgrades. (Sorry to those folks that I didn't catch or fix this sooner.)

There was an existing ServiceDynamicReturnTypeExtension return type extension, which I renamed to ContainerDynamicReturnTypeExtension since it handled ContainerInterface::get and ContainerInterface::has calls. Then I just needed to make the DrupalServiceDynamicReturnTypeExtension return type extension that targets the \Drupal::service method. And voila!

I then made a matching StaticServiceDeprecatedServiceRule to check if the service name passed to \Drupal::service was deprecated.

Here's that fix's live stream recording

Catch my live development streams!

All of my development streams are live on my Twitch channel and recordings are exported to my YouTube channel.

  • Mondays @ 2 PM: Simplytest.me maintenance and development
  • Wednesdays @ 2 PM: Drupal upgrade tools development, sponsored by Palantir.net
  • Thursdays @ 2 PM: PHPStan development, sponsored by Esteemed, Inc

Want to sponsor my open source development work?

  • You can sponsor my work through GitHub Sponsors
  • Interested in sponsoring a live development stream? I can work on something of your choosing (not just Drupal upgrade tooling)! Contact me through my website. Episodes are two hours long for $300 USD.