Static Code Analysis in PHP

How to write less tests

Benjamin Cremer

Benjamin Cremer
@benjamincremer
Organizer of the PHP Usergroup Münster
Lead Developer at CHECK24

Questions?

Feel free to ask at any time

Types of Programming Errors

Logical Errors

Logical Errors


public function addNumbers(float $a, float $b): float
{
    return $a - $b;
}
            

Compile Time Errors

Syntax Errors

A special kind of compile time error


function foo() {
    bar()
    $baz
}
    

# Run PHP Syntax check (lint)
$ php -l example.php
PHP Parse error:  syntax error, unexpected '$baz' (T_VARIABLE)
Errors parsing example.php
    

# "compile" code
# Syntax errors are also compile-time errors
$ php example.php
PHP Parse error:  syntax error, unexpected '$baz' (T_VARIABLE)
    

Run Syntax Checks with PHP Lint

  • First line of defence
  • Easy to implement
  • Fast

Syntax Check with PHP Parallel Lint

Wrapper around php -l with several options for your convenience.


                composer require --dev jakub-onderka/php-parallel-lint
                ./vendor/bin/parallel-lint -j 24 src/
            
Screenshot of PHP-Parallel-Lint

Compile Time Errors


interface Car
{
    public function drive(): int;
}

class Tesla implements Car
{
    public function drive(): string
    {
        return 'wrooom';
    }
}
            

$ php -l example.php
No syntax errors detected in example.php
            

$ php example.php
PHP Fatal error:  Declaration of Tesla::drive(): string must
be compatible with Car::drive(): int in example.php
            

PHP has very few compile time errors...

... But it is getting better.

PHP RFC: Always generate fatal error for incompatible method signatures

Runtime Errors


class Car
{
    public function drive(): void {}
}

function testdrive(Car $car) {
    $car->fly();
}
            

$ php -l example.php
No syntax errors detected in example.php
                    

$ php example.php
(Exit code: 0) // no errors found
               

class Car
{
    public function drive(): void {}
}

function testdrive(Car $car) {
    $car->fly();
}

testdrive(new Car()); // execute above code
            

$ php example.php
PHP Fatal error:  Uncaught Error: Call to undefined method Car::fly()
in example.php:9
               

class Car
{
    public function drive(): void {}
}

function testdrive(Car $car, bool $fly) {
    if ($fly) {
        $car->fly();
    } else {
        $car->drive();
    }
}
            

testdrive(new Car(), false); // works just fine
           

testdrive(new Car(), true); // kaboom
           

Write Tests?

Write a lot of Tests?

Static Code Analysis

What is Static Analysis?

Predict the behavior of software without running it.

Promote runtime errors to compile time errors.

Find (a class of) bugs in your code without writing Tests!

The analyser needs your help

Type Inference

Inferring types when types are not given.


function addStrings($part1, $part2)
{
    // concat operation always yields a string
    return $part1 . $part2;
}

// $foo can be inferred as string
$foo = addStrings($unknownA, $unknownB);
            

Type Inference

Type-specifying language constructs and functions


if (is_int($variable)) {
    // here we can be sure that $variable is integer
}
            

/**
 * @param DateTimeInterface $dateTime
 * @param int $days
 * @return \DateTimeInterface
 */
function addDays($dateTime, $days) {
    [...]
}
            

function addDays(
    \DateTimeInterface $dateTime,
    int $days
): \DateTimeInterface {
    [...]
}
            

/**
 * @return string[]
 */
function getDaysOfWeek(): array {
    return [
        'monday',
        'tuesday',
        [...]
    ];
}
            

/**
 * @return array<string,int>
 */
function getDayOfWeekMap(): array {
    return [
        'monday' => 1
        'tuesday' => 2,
        [...]
    ];
}
            

            /**
             * @method Booking|null findOneByUuid(string $uuid)
             * @method Booking[] findByUuid(string $uuid)
             */
            class BookingRepository extends EntityRepository
            {
                public function __call($method, $arguments) {}
            }
            

Add types for third party code


/** @var \DateTimeInterface $date */
$date = Thirdparty::createDate();
            

$date = Thirdparty::createDate();
assert($date instanceof \DateTimeInterface);
            
via: https://github.com/doctrine/coding-standard/pull/47

Some tools can tell you how discoverable your codebase is


Checks took 3.48 seconds and used 604.080MB of memory
Psalm was able to infer types for 95.3959% of the codebase
            

PHP-CS-Fixer and PHP_CodeSniffer can assist you with that


                composer require --dev doctrine/coding-standard
            

function getDaysOfWeek(): array {
    return [
        'monday',
        'tuesday',
        [...]
    ];
}
            

$ ./vendor/bin/phpcs src/CodeStyle.php

FILE: /tmp/src/CodeStyle.php
-----------------------------------------------------------------
FOUND 1 ERROR AFFECTING 1 LINE
-----------------------------------------------------------------
 9 | ERROR | Method \Examples\CodeStyle::getDaysOfWeek() does not
   |       | have @return annotation for its traversable return
   |       | value.
-----------------------------------------------------------------
            

Primitive Obsession


                function publishPost(
                    int $userId,
                    int $postId,
                    bool $isActive,
                    bool $isDraft // added later on
                )
            

$postId = (int)$request->get('post_id');
$userId = (int)$request->get('user_id');
// many lines of code ...

publishPost(
    $postId,
    $userId, // doh!
    true,
    true // is that even valid?
);
            

Introduce Value Objects and Enums


                function publishPost(
                    UserId $userId,
                    PostId $postId,
                    PostStatusEnum $status
                )
            

$postId = PostId::fromInt((int)$request->get('post_id'));
$userId = UserId::fromInt((int)$request->get('user_id'));
// many lines of code ...

publishPost(
    $postId,
    $userId, // can be detected via SCA
    PostStatusEnum::DRAFT()
);
            

Plain old PHP objects (POPO)


final class UserId
{
    private $userId;

    public static function fromInt(int $userId) : self
    {
        return new self($userId);
    }

    private function __construct(int $userId)
    {
        // one test case less for the consuming code
        Assert::greaterThan($userId, 0);

        $this->userId = $userId;
    }

    public function toInt() : int
    {
        return $this->userId;
    }
}
            

            // own Date ValueObject with reduced precision
            $startDate = Date::fromString('2019-05-04');

            Stay::fromDates(
                $startDate,
                $startDate->addDays(5)
            );

            Stay::fromStrings('2019-05-04', '2019-05-04');
            // ensures bussiness restrictions
            // throws exception: 'Start Date (%s) must be before end date (%s).',

            $stay = Stay::fromStrings('2019-05-04', '2019-05-06');
            $stay->getNumberOfNights(); // 2
            

Types vs. Tests

Ideology

A talk by Gary Bernhardt from Strange Loop 2015

destroyallsoftware.com/talks/ideology

Abstract syntax tree

PHP AST Parsers

nikic/php-parser by Nikita Popov
A PHP parser written in PHP

nikic/php-ast by Nikita Popov
Extension that exposes the abstract syntax tree generated by PHP 7

nikic/php-parser Demo


composer require nikic/php-parser
./vendor/bin/php-parse src/ast_example.php
            

Gimme the tools

PHPStorm + Php Inspections
(EA Extended)

Not very CI friendly

There is a inspection.sh for headless mode

vimeo/psalm phan/phan phpstan/phpstan
Author Vimeo Rasmus Lerdorf Ondřej Mirtes
PHP Version >= 5.6 >= 7.0 >= 7.1
AST PHP-Parser ext-ast PHP-Parser

And so much more: https://github.com/exakat/php-static-analysis-tools

PHPStan

  • fast
  • extensible
  • in active development
  • used in Build-Pipelines at CHECK24 Hotels

Usage


                composer require --dev phpstan/phpstan
                ./vendor/bin/phpstan analyse --level=2 src/
            

namespace Example;

class Testdriver
{
    public static function testdrive(Car $car, bool $fly)
    {
        if ($fly) {
            $car->fly();
        } else {
            $car->drive();
        }
    }
}
            

$ ./vendor/bin/phpstan analyse --level=2 src/Testdriver.php
            

Runtime Error promoted to Compile Time Error 🎉

We found a code defect without writing a test 🎉

PHPStan Tips

  • Use "ignoreErrors"-Regex (baseline)
  • Level up fast up to Level 4-5
  • Low level in CI, higher level in Dev
  • Adjust level per Module
  • Repl: https://phpstan.org/
  • PHPStan is a tool to help you, not to hinder you

Thank you!

Please leave feedback

@benjamincremer
talks.benjamin-cremer.de/ipc_sca

PHP Usergroup Münster

meetup.com/phpugms
@phpugms