Static Code Analysis in PHP - Benjamin Cremer

Static Code Analysis in PHP

How to write less tests

Benjamin Cremer

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

Disclaimer

Not doing Symfony since 2017

Does not matter for todays topic

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.

Screenshot of PHP-Parallel-Lint

                composer require --dev jakub-onderka/php-parallel-lint
                ./vendor/bin/parallel-lint -j 24 src/
            

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,
        [...]
    ];
}
            

/**
 * @return callable(int): int
 */
function createAdder(int $x): callable {
    return function(int $y) use ($x): int {
        return $x + $y;
    };
}

$addFive = createAdder(5);
$addFive(10);
// output: int(15)
            

/**
 * @param callable(string): string $transformer
 */
function greet(string $input, callable $transformer): string {
    return 'Hello ' . $transformer($input);
}

$toUpperCase = function (string $x): string { 
    return strtoupper($x);
};

greet('hello symfony live', $toUpperCase);
// string(32) "HELLO SYMFONY LIVE"
            

            /**
             * @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

Union Tpyes


/**
 * @return int|false
 */
function stripos ($haystack, $needle) {}
            

/** @var string|array $rabbit */
$rabbit = rand(0, 10) === 4 ? 'rabbit' : ['rabbit'];
            

Intersection types


/** @var CodeFormatter&\PHPUnit\MockObject */
$formatter = $this->createMock(CodeFormatter::class);

$formatter->expect(...); // method from MockObject
$formatter->format(...); // method from CodeFormatter
            

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;
    }
}
            

use MyCLabs\Enum\Enum;

class PostStatusEnum extends Enum
{
    private const DRAFT     = 'draft';
    private const PUBLISHED = 'active';
    private const HIDDEN    = 'hidden';
}
            

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

            $endDate->equals($startDate); // false
            $endDate->isLaterThan($startDate); // true

            $startDate->addDays(5); // return new Date Object
            

            Stay::fromDates(
                $startDate,
                $endDate,
            );

            // 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
            

Helpful Tools built on the AST

SCA in PHP

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

PHPStan

  • easy to start with
  • fast
  • extensible
  • in active development

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

  • Start with level 0 and fix the worst
  • Use "ignoreErrors"-Regex
  • Be careful not to ignore too much
  • Create 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/

github.com/phpstan/phpstan-symfony

Be careful


ignoreErrors:
    - '#Cannot call method getResult() on array|object|string.#'
            

Psalm

  • Not so intuitive error messages
  • Dead/Unused Code detection
  • Baseline support
  • Code Fixer and Refactoring tools
  • Templates and Assertations

Psalm


composer require --dev vimeo/psalm
./vendor/bin/psalm --init src/ 8
./vendor/bin/psalm
            

./vendor/bin/psalm src/Testdriver.php
Scanning files...
Analyzing files...

ERROR: UndefinedMethod - src/Testdriver.php:12:19 - 
Method Example\Car::fly does not exist

            $car->fly();

------------------------------
1 errors found
------------------------------

Checks took 0.14 seconds and used 42.155MB of memory
Psalm was able to infer types for 96.2963% of the codebase
            

Prefixed annotations


/**
 * @param 'a'|'b' $s
 * @psalm-param 'a'|'b' $s
 */
function foo(string $s) : string {
  switch ($s) {
    case 'a':
      return 'hello';

    case 'b':
      return 'goodbye';
  }
}
            

Prefixed annotations


/**
 * @param class-string $s
 */
function takesClassName(string $s) : void {}
            

Psalm Object-like arrays


            return [
                "foo" => 'fasel'
                "bar" => false
            ];
            

            /** @return array{foo: string, bar: bool} */
            

Psalm Baseline


./vendor/bin/psalm --set-baseline=your-baseline.xml
./vendor/bin/psalm --update-baseline
            

Psalm Alter

psalm.dev/docs/manipulating_code/fixing/


./vendor/bin/psalter  --issues=all --dry-run --safe-types
            

Annotating immutability


@psalm-readonly (for properties)
@psalm-pure
@psalm-mutation-free
@psalm-external-mutation-free
@psalm-immutable (for classes)
            

Your turn!

Give phpstan and/or psalm a try today

SCA is a tool to help you, not to hinder you

Thank you!

Please leave feedback

joind.in/talk/a97b6
@benjamincremer
talks.benjamin-cremer.de/symfony_live_sca_2019

PHP Usergroup Münster

meetup.com/phpugms
@phpugms