Improve PHP code quality with static analysis

Learn how to write a custom PHPStan rule to prevent “code smell”

Shortly about static code analyzing

Static code analysis is a method of debugging by examining source code before a program is run. It's done by analyzing a set of code against a set (or multiple sets) of coding rules. Static code analysis and static analysis are often used interchangeably, along with source code analysis.

What is PHPStan?

One of the most popular static analysis tools for the PHP language right now is PHPStan:

PHPStan focuses on finding errors in your code without actually running it. It catches whole classes of bugs even before you write tests for the code. It moves PHP closer to compiled languages in the sense that the correctness of each line of the code can be checked before you run the actual line.

Find a problem and propose a solution

Why you should be worried about catching the \Throwable exception?

Lets start with a code example:

try {
    // do whatever you want
} catch (\Throwable $e) {
    // hide everything, even PHP errors
}

This is dangerous, due to the usage of the top-level exception interface \Throwable, which means that every other exception must implement this interface. PHP fatal errors, built-in and user-land exceptions will be caught in such cases. This is dangerous as it increases the maintenance cost, and potentially hides code issues from developers.

PHPStan for a rescue!

PHPStan's core concepts are:

  • Abstract Syntax Tree,
  • Scope,
  • Type System,
  • Trinary Logic,
  • Reflection,
  • Dependency Injection & Configuration.

I will not get into all details in this article, as our custom rule will base just on the AST:

Abstract Syntax Tree (AST) is the way analyzed source code is represented in the static analyzer so that it can be queried for useful information.

For more information about the PHPStan core concept, I recommend reading an official documentation.

New custom rule to prevent catching \Throwable interface

Writing custom rules for PHPStan will allow it to detect this bug-prone code part and force developers to write it in a better way, with more bullet-proof code.

First let's create a new class: DisallowCatchingThrowableExceptionRule, we need also to implement PHPStan\Rules\Rule interface:

<?php declare(strict_types=1);

namespace App;

use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;

class DisallowCatchingThrowableExceptionRule implements Rule
{
    public function getNodeType(): string
    {
        return '';
    }

    /**
     * @return string[]
     */
    public function processNode(Node $node, Scope $scope): array
    {
         return [];
    }
}

Our new rule will work on the PHPParser statements classes, and in fact, it will be two of those:

  • PhpParser\Node\Stmt\TryCatch, related to try {} catch () {} code fragment,
  • PhpParser\Node\Stmt\Catch_, related to catch () code fragment.

We now also need to change the value of the getNodeType() method, to point PHP parser that we will work on statement level.

<?php declare(strict_types=1);

namespace App;

use PhpParser\Node;
use PhpParser\Node\Stmt;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;

class DisallowCatchingThrowableExceptionRule implements Rule
{
    public function getNodeType(): string
    {
        return Stmt::class;
    }

    /**
     * @return string[]
     */
    public function processNode(Node $node, Scope $scope): array
    {
        if ($node instanceof Stmt\TryCatch) {
            return [];
        }

        return [];
    }
}

When you inspect the object of Stmt\TryCatch, you will notice it contains the $catches variable, which is an array of nodes: Stmt\Catch_. Now we can check what class name was used in it and compare it against the name of the \Throwable interface!

    /**
     * @return string[]
     */
    public function processNode(Node $node, Scope $scope): array
    {
        if ($node instanceof Stmt\TryCatch) {
            foreach ($node->catches as $catch) {
                foreach ($catch->types as $type) {
                    // To compare object against string value, we need to cast it back to string,
                    // we can use toString() helper method for that.
                    if (\Throwable::class !== $type->toString()) {
                        continue;
                    }
                }
            }

            return [];
        }

        return [];
    }

Now we need to mark that try {} catch () {} statement as broken. To do that we should add a custom attribute on the $catch that was a match against the \Throwable interface:

    /**
     * @return string[]
     */
    public function processNode(Node $node, Scope $scope): array
    {
        if ($node instanceof Stmt\TryCatch) {
            foreach ($node->catches as $catch) {
                foreach ($catch->types as $type) {
                    if (\Throwable::class !== $type->toString()) {
                        continue;
                    }

                    // Name of the attribute should be unique one to prevent issues with other PHPStan rules
                    $catch->setAttribute('__THROWABLE_NODE_ATTRIBUTE__', 'You cannot catch the \Throwable exception');

                    // We already found a "broken" catch, no need for further checks
                    break 2;
                }
            }

            return [];
        }

        return [];
    }

We have marked the broken part of the code, but it's not yet reported by PHPStan. To do that, we need to check if any processed node contains our newly set attribute and if so return an array of errors.

    private const THROWABLE_NODE_ATTRIBUTE = '__THROWABLE_NODE_ATTRIBUTE__';

    /**
     * @return string[]
     */
    public function processNode(Node $node, Scope $scope): array
    {
        if ($node instanceof Stmt\TryCatch) {
            foreach ($node->catches as $catch) {
                foreach ($catch->types as $type) {
                    if (\Throwable::class !== $type->toString()) {
                        continue;
                    }

                    $catch->setAttribute(self::THROWABLE_NODE_ATTRIBUTE, 'You cannot catch the \Throwable exception');

                    break 2;
                }
            }

            return [];
        }

        if ($node instanceof Stmt\Catch_ && $node->hasAttribute(self::THROWABLE_NODE_ATTRIBUTE)) {
            return [$node->getAttribute(self::THROWABLE_NODE_ATTRIBUTE)];
        }

        return [];
    }

Last but not least, you need to declare the newly created rule in your phpstan.neon file:

rules:
    - App\DisallowCatchingThrowableExceptionRule

If everything goes well, on the next usage of PHPStan you will notice a new error reported if your code contains: catch (\Throwable $e):

$ vendor/bin/phpstan analyse some_file.php
Note: Using configuration file /srv/phpstan.neon.
 1/1 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%

 ------ ----------------------------------------------------------
  Line   some_file.php
 ------ ----------------------------------------------------------
  666    You cannot catch the \Throwable exception
 ------ ----------------------------------------------------------

Summary

From this article, you could learn the basics of static analysis in the PHP language and how you could start writing your own custom rules for PHPStan. For more details, I highly recommend reading official PHPStan documentation about custom rules development.

Ready to use class as a gist:

Easy to copy version of this rule can be found in this gist.