Skip to main content

Writing better Drupal code with static analysis using PHPStan

Published on

PHP is a loosely typed interpreted language. That means we cannot compile our scripts and find possible execution errors without doing explicit inspections of our code. It also means we need to rely on conditional type checking or using phpDoc comments to tell other devs or IDE what kind of value to expect. Really there is no way to assess the quality of the code or discover possible bugs without thorough test coverage and regular review.

If you use PhpStorm, you will notice all of their helpers which analyze your code and add static analysis. Such as "Watch out, there's a good chance you did not catch this exception!" PhpStorm reads the phpDoc annotations to help provide type checking as well. This gets kicked up to level 100 using the Php Inspections (EA/SA) plugin.

That's awesome. It's pretty amazing that PhpStorm and a few plugins can give us some stability in our PHP code that allows acting like we might be working in a compiled language (okay, that's a stretch, but the point is there.)

There are a few problems with this approach, though

  1. Writing custom inspections, like for Drupal, requires learning Java and building a PhpStorm plugin.
  2. Everyone on the development team needs to have PhpStorm
  3. You can't execute this over a CI process and make it codified.

But, wait! What about PHP_CodeSniffer? PHPCS gives us some benefits, but it is not a full static analysis tool that would help us find bugs in our software. When running PHPCS, individual files are tokenized and then parsed. This allows for a line-by-line analysis of an individual file. It does not, however, let you check if the class you referenced actually exists or not. Or that your method expects MySpecificObjectInterface but in reality, several calls will pass  SomeOtherObjectInterface

There are a quite few static analysis tools out there, but none will work with Drupal out of the box. Why? Because Drupal has a magical autoloading system that does not get dumped into Composer's autoloader. The tool needed to extendable. And that's how I found PHPStan. What I really like about PHPStan is that it is able to inspect your entire codebase and find out if a class does not exist, if it is called incorrectly, like you actually compiled your PHP project and didn't even run it.

So, what happens when you try to run PHPStan and analyze a Drupal module (or core) out of the box?

PHPStan executed against the Address module, without the Drupal extension

Nothing. Because PHPStan has no idea how to load any of the files. PHPStan relies on being able to load classes or functions through Composer's generated autoload information. When you enable a module or a theme, none of the information on how to load its files is added to the Composer autoload information. All of that data is set up when Drupal's container is built. 

A Drupal extension for PHPStan

I would like to introduce phpstan-drupal. I spent two weeks in December working on this extension so that I could analyze Drupal Commerce, our dependencies, and Drupal core itself. It was definitely a fun challenge and even uncovered a bug in Drupal core due to a duplicate function name in a test module.

PHPStan run against the state_machine module, with the Drupal extension

To get started, you need to add mglaman/phpstan-drupal as a developer dependency.

composer require mglaman/phpstan-drupal --dev

Then create a phpstan.neon file. Here's an example that I am using

parameters:
	# Ignore tests
	excludes_analyse:
		- *Test.php
		- *TestBase.php
	# PHPStan Level 1
	level: 1
includes:
	# Add the phpstan-drupal extension
	- vendor/mglaman/phpstan-drupal/extension.neon

Now, let's dive into some more details

Bootstrapping Drupal's autoloading and namespaces without a database

Everything about Drupal's bootstrap and container requires the database. At first, I had tried initializing DrupalKernel and only touching methods which did not reach into the database (or try to at least mock it.) That was a big failure. I wish I had started writing this blog as I went down those rabbit holes.

The extension supports discovering Drupal in the following scenarios vanilla Drupal project setup and Composer project template setups with either the web or docroot directory.

The first task was to make copies of the extension discovery classes. Since Drupal core has a dependency on Symfony 3 it could not be added as a developer dependency -- PHPStan uses Symfony 4's Console component.

$this->extensionDiscovery = new ExtensionDiscovery($this->drupalRoot);
$this->extensionDiscovery->setProfileDirectories([]);
$profiles = $this->extensionDiscovery->scan('profile');
$profile_directories = array_map(function ($profile) {
  return $profile->getPath();
}, $profiles);
$this->extensionDiscovery->setProfileDirectories($profile_directories);
$this->moduleData = $this->extensionDiscovery->scan('module');
$this->themeData = $this->extensionDiscovery->scan('theme');

There's a lot to walk through, but the source is here: https://github.com/mglaman/phpstan-drupal/blob/master/src/Drupal/Bootst…. This loads legacy include files, adds namespaces, ensures extension files can be loaded, and hook_hook_info files.

Return typing from the service container

When you fetch a service from the container nothing defines what should be returned. In PhpStorm, you can use the Drupal Symfony Bridge to provide typing from the services container. For example, entity_type.manager would knowingly be EntityTypeManagerInterface.

The extension loads all available services.yml from core and modules. This is then parsed and put into a ServicesMap. I borrowed the concepts from the PHPStan Symfony extension. However, the Symfony container gets dumped and is easier to load and parse, not nearly as dynamic as Drupal

foreach ($extensionDiscovery->scan('module') as $extension) {
  $module_dir = $this->drupalRoot . '/' . $extension->getPath();
  $moduleName = $extension->getName();
  $servicesFileName = $module_dir . '/' . $moduleName . '.services.yml';
  if (file_exists($servicesFileName)) {
    $serviceYamls[$moduleName] = $servicesFileName;
  }
  $camelized = $this->camelize($extension->getName());
  $name = "{$camelized}ServiceProvider";
  $class = "Drupal\\{$moduleName}\\{$name}";
  if (class_exists($class)) {
    $serviceClassProviders[$moduleName] = $class;
  }
}

The PHPStan Drupal extension implements a DynamicMethodReturnTypeExtension rule that will return the proper class object type based on the requested service.

There are some gotchas. It does not work well for services which define callsfactory, and configurator.

Dynamic return typing for entity storage from the entity type manager

The extension also provides a DynamicMethodReturnTypeExtension for the entity type manager service. Since modules have custom entities and custom storages, this is something which can be configured in your phpstan.neon file. The extension provides some defaults

drupal:
	entityTypeStorageMapping:
		node: Drupal\node\NodeStorage
		taxonomy_term: Drupal\taxonomy\TermStorage
		user: Drupal\user\UserStorage

What else does it have?

  • GlobalDrupalDependencyInjectionRule: don't call \Drupal::service when dependency injection is possible (performance, code standards)
  • DiscouragedFunctionsRule: copied from phpcs
  • PluginManagerSetsCacheBackendRule: catch a plugin manager which does not set a cache backend for its definitions (performance.)
  • EnhancedRequireParentConstructCallRule: improves handling of empty parent constructor calls. YAML plugin managers do not call their instructor. Need to abstract our some other plugin manager assertions.

What's next?

I have no idea!

I'm available for one-on-one consulting calls – click here to book a meeting with me 🗓️