Strict Development CI for PHP

Kpicaza
Building the Wine&Spirits Marketplace
11 min readApr 12, 2022

Hey, folks. As promised in our previous post about Async PHP, we already have the project running in production for some sections of our applications;-D. The search middleware has good enough performance using Symfony MicroKernel + Road Runner as a server, as we spiked a few months ago. Achievement unlocked!!!

This time, we want to explain how to manage a strict development continuous integration pipeline in PHP. We will create an example application with the same tools we use for our production project. That means respecting the best practices and standards of the PHP language.

Development Continuous integration?

We call Development Continous integration to all the actions executed to test if our code is mergeable with our main branch in Git.

In our case, this means, as a minimum requirement to make a commit locally:

  • The code added to the project respects the PSR-12 Coding standards and PSR-4 Namespacing?
  • Has the code any syntax error?
  • The test suite is currently passing?
  • Is there any known pattern that we can automatically fix to a more modern approach?

Also, we check without breaking a commit:

  • Does the new feature have unit tests?
  • Do tests cover code changes related to the new feature?

You can think at this point that a Pull Request Review will be sufficient to cover all the questions. The answer is yes, but if we hold all the responsibility in the reviews, it will become a stopper on the way of the code to production. Every PR must be well contextualized. Also, the reviewers need to have sufficient knowledge of the global picture to check the correctness or not.

That is not bad as is, but we love to automate things to optimize our processes. In our case, we want any developer of any team in the company to be able to review with the minimum effort our Pull requests. Also, we do not want to write an entire post to give context to PR reviewers out of our team.

To make these tasks possible, we rely on a small PHP toolset. We will explain in detail each step.

Tools

  • Coding Standards: We use different coding standard tools on our projects; in more legacy, we use Squizlabs Php Codesniffer. Recently, we started using Simplify Easy Coding Standard, which has a shorter config.
  • Static Analysis: The modern PHP static analysis tools like Vimeo Psalm or PHPStan become game-changers. In addition to the progress on the types topic of the newer versions of PHP, you can put your language requirements very similar to other more strict typed languages like TypeScript.
  • Unit Tests + Mutation Testing: PHPUnit tests by themselves do not guarantee the test quality. In addition, we use the Infection mutation testing tool to test our tests. The mutation testing tool generates changes in our code to check if the unit tests detect those changes. Infection registers in at least three different states. Uncovered: there is not any test that covers changes. Mutant: the test does not fail to respond to a change. Killed mutant: our tests fail when code changes.
  • Automated Refactoring: Rector is an excellent tool created by Tomas Votruba. This tool refactors our code for us. We only need to configure matching rules to detect anti-patterns, smells, or update some expressions to take profit from PHP’s capabilities. It will automatically fix them. We need to take care of our application. It requires excellent test coverage to enable automated refactoring tasks securely.
  • Pre-Commit Hooks: We use GrumPHP to manage our pre-commit hooks. It gives us an interface to run any command against our code before admitting a commit.

The Benefits of combining these tools

There are a lot of tools and many concepts to take care of before committing? Yes. Does it guarantee a minimum code quality before entering a Production pipeline? Also, Yes.

Respecting the coding standards guarantees the code legibility. Static analysis tools take care of getting our code clean of silly bugs like syntax errors. Unit testing, in addition to mutant testing, helps us deliver code that does what it has to do. At last, the automated refactor tool fixes some blocks of code previously configured using pattern rules for us.

As we explained, every action we take before committing does not avoid pull-request reviews. However, it does make it much faster because the reviewer’s work decreases significantly, while every concept we discuss here will be checked automatically at opening a pull request. Developers only need to contextualize the feature to deliver and not tell the history of why some previous requirement, for example, was done in some manner.

In other words, it allows both developers and reviewers to focus more on the functionality itself.

Hands-On

As we promise, we will create an application to put into practice what we talk before. If you fill lazy, you can check the example repository on Github.

Requirements:

  • PHP ≥8.1 with XDebug extension available
  • Composer package manger

Create an empty PHP project using composer:

mkdir test-dev-ci && cd test-dev-ci
composer init

It shows a prompt asking for a package name, description, license, package type, and dependencies. Do not worry too much at the moment about dependencies. We will rework it later one by one:

After responding to all questions in the console, we will have created thecomposer.json file in our project root folder. Open it in your editor. We will tweak it a little before start installing the dependencies. The current status should be something like that:

{
"name": "drinksandco/test-dev-ci",
"description": "Test development continuous integration pipeline",
"type": "project",
"license": "MIT",
"autoload": {
"psr-4": {
"TestDevCi\\": "src/"
}
},
"require": {
"php": "^8.1"
}
}

Start installing the Symplify Easy Coding Standard library using the Composer package manager

composer require symplify/easy-coding-standard — dev

We need to create a file and name it ecs.php in the project root.

<?php

declare
(strict_types=1);

use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symplify\EasyCodingStandard\ValueObject\Option;
use Symplify\EasyCodingStandard\ValueObject\Set\SetList;

return function (ContainerConfigurator $containerConfigurator): void {
$containerConfigurator->import(SetList::PSR_12);
$containerConfigurator->import(SetList::STRICT);
};

That is all the config we need to set up ECS. Using the ContainerConfigurator class, we load the Symplify PSR-12 ruleset within the Symplify strict ruleset.

Add a new file named App.phpinside the foldersrc with some wrong formatted code.

<?php

namespace
TestDevCi;

class App {
public function doSomething() {
$foo= (string)23;
return $foo;
}
}

Now we can run the ECS tool in the console.

vendor/bin/ecs check src

It will prompt the following output:

It detects when our code does not respect the coding standard and suggests the correct version for us. We can apply these changes automatically by running this command:

vendor/bin/ecs check src --fix

Next, we want to install Vimeo/Psalm and PHPStan static analysis tools.

We can start by installing PHP Stan:

composer require phpstan/phpstan --dev

PHP Stan evaluates your code against predefined rules where we can set the strictness of our code. In our case, we want the most strict options. That means running PHPStan in level 9.

vendor/bin/phpstan analyse src -l9

As we can see, the command fails because our code does not fit our defined strictness. Install Vimeo Psalm before fixing it.

composer require vimeo/psalm --dev

Vimeo Psalm requires a little config, and as we made before with PHPStan, we can set our code strictness to the maximum level:

vendor/bin/psalm --init

This command creates the psalm.xml file for us, setting as default strictness level the one who it detects your code will pass next. In our case, it sets it to 3, and we want to be 1, the most strict level in Psalm. Change it in the config file.

<?xml version="1.0"?>
<psalm
errorLevel="1"
resolveFromConfigFile="true"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://getpsalm.org/schema/config"
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
>
<projectFiles>
<directory name="src" />
<ignoreFiles>
<directory name="vendor" />
</ignoreFiles>
</projectFiles>
</psalm>

Now run psalm check:

vendor/bin/psalm

In that case, it detects the same issue we noticed before with PHPStan. It is ok, both programs have different presets, and both programs warn about diverse situations also. Now we can fix it.

<?php

declare
(strict_types=1);

namespace TestDevCi;

class App
{
- public function doSomething()
+ public function doSomething(): string
{
$foo = (string)23;
return $foo;
}
}

Fixed, now we can check both PHPStan and Psalm. Both are passing.

At this point, we are ensuring our coding standards and our code strictness. The next is to add a unit testing layer by installing the PHPUnit testing framework.

composer require phpunit/phpunit --dev

We can create a PHPUnit config by running the following command:

vendor/bin/phpunit --generate-configuration

It will create the phpunit.xml file for us. I usually modify it a little to not force the cover option:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.5/phpunit.xsd"
bootstrap="vendor/autoload.php"
cacheResultFile=".phpunit.cache/test-results"
executionOrder="depends,defects"
- forceCoversAnnotation="true"
- beStrictAboutCoversAnnotation="true"
+ colors="true"
+ forceCoversAnnotation="false"
+ beStrictAboutCoversAnnotation="false"
beStrictAboutOutputDuringTests="true"
beStrictAboutTodoAnnotatedTests="true"
convertDeprecationsToExceptions="true"
failOnRisky="true"
failOnWarning="true"
verbose="true">
<testsuites>
<testsuite name="default">
<directory>tests</directory>
</testsuite>
</testsuites>

<coverage cacheDirectory=".phpunit.cache/code-coverage"
processUncoveredFiles="true">
<include>
<directory suffix=".php">src</directory>
</include>
</coverage>
</phpunit>

Enable the autoload-dev section, inner composer.json file:

...
"autoload": {
"psr-4": {
"TestDevCi\\": "src/"
}
},
+"autoload-dev": {
+ "psr-4": {
+ "TestDevCi\\Test\\": "tests/"
+ }
+},
"require": {
...

Now we need to create at least one test for our code:

<?php
// tests/AppTest.php
namespace
TestDevCi\Test;

use TestDevCi\App;
use PHPUnit\Framework\TestCase;

class AppTest extends TestCase
{
public function testItReturnANumericString(): void
{
$app = new App();

$this->assertSame('23', $app->doSomething());
}
}

And we can run the suite by:

vendor/bin/phpunit

With this, we reach our minimum development continuous integration requirements. We will install the Grumphp library to automatize those tasks to run every local commit.

composer require --dev phpro/grumphp

We can configure one by one every task in the grump config, but we prefer to manage it using composer scripts:

    "requere-dev": {
...
+ "scripts": {
+ "precommit-check": [
+ "@cs-check",
+ "@test",
+ "@inspect",
+ "@psalm"
+ ],
+ "cs-check": "ecs check src",
+ "cs-fix": "ecs check src --fix",
+ "inspect": "phpstan analyse src -l9 --ansi",
+ "test": "phpunit --colors=always",
+ "psalm": "psalm"
+ }
}

And also, we need to specify these options to the GrumPHP config file:

# grumphp.yml
grumphp:
environment:
variables:
GRUMPHP_GIT_WORKING_DIR: .
GRUMPHP_BIN_DIR: vendor/bin
tasks:
composer_script:
script: precommit-check
triggered_by: [php]
working_directory: ~

Try to make a commit:

git add composer.json ecs.php phpunit.xml psalm.xml grumphp.yml tests/ src/
git commit -S -m 'Preparing dev ci ;-D'

And voila, we configure all our checks to run before creating a commit. If one of those tasks fails, the developers will not have the option to persist the commit since them does not fix the issues detected by our pipeline.

During the post, we say that the unit tests by themselves do not check what we want to gain from them. To fix this, we use the Infection mutation testing library. This library will be responsible for testing our tests. Install it.

composer require infection/infection --dev

We have to add a config file namedinfection.json.dist

{
"source": {
"directories": [
"src"
]
},
"mutators": {
"@default": true
},
"logs": {
"text": "infection.log"
}
}

When we run the infection command, it will create a report for us giving much more trustable coverage data than using default code coverage tools.

XDEBUG_MODE=coverage vendor/bin/infection

And the last step to have our top-strictness development pipeline is to install the Rector PHP tool.

composer require rector/rector --dev

Rector has a collection of rules you can select to fit your application requirements. For our project, we use the following config in the rector.php file:

<?php
// rector.php
declare
(strict_types=1);

use Rector\Core\Configuration\Option;
use Rector\Php71\Rector\FuncCall\CountOnNullRector;
use Rector\Php80\Rector\Switch_\ChangeSwitchToMatchRector;
use Rector\Php81\Rector\FuncCall\NullToStrictStringFuncCallArgRector;
use Rector\Set\ValueObject\LevelSetList;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

return static function (ContainerConfigurator $containerConfigurator): void {
$parameters = $containerConfigurator->parameters();
$parameters->set(Option::PATHS, [
__DIR__ . '/src'
]);
$parameters->set(Option::SKIP, [
NullToStrictStringFuncCallArgRector::class,
ChangeSwitchToMatchRector::class,
CountOnNullRector::class,
]);

$containerConfigurator->import(LevelSetList::UP_TO_PHP_81);
};

We are applying the Rector rule preset for PHP 8.1. Also, skipping some of the rules to avoid conflicts. Be aware Rector changes your code. To use it securely, you will have to be very strict in every previous step.

Check if it works:

vendor/bin/rector process src --dry-run

At the current point, everything is ok, and Rector can not do anything. We can force it by changing our App class by appending this bad-formatted code block before our method.

/** @var string  */
private
$foo;

public function __construct(string $foo = '') {
$this->foo = $foo;
}

After re-running the dry-run command, it will detect some pattern that it knows how to fix or update.

vendor/bin/rector process src --dry-run

We run the same command without the --dry-run flag it will apply the changes:

vendor/bin/rector process src

Rector does not take care of coding standards, so if we run rector changeset, we are encouraged to run also the cs-fix command.

composer cs-fix

To end our environment configuration, we will add those two last commands to our composer script section to be able to run them handily.

"scripts": {
"precommit-check": [
"@cs-check",
"@test",
"@inspect",
"@psalm"
],
+ "check-all": [
+ "@cs-check",
+ "@test",
+ "@inspect",
+ "@psalm",
+ "@infection",
+ "@rector-check"
+ ],
"cs-check": "ecs check src",
"cs-fix": "ecs check src --fix",
"inspect": "phpstan analyse src -l9 --ansi",
"test": "phpunit --colors=always",
"psalm": "psalm",
+ "infection": "XDEBUG_MODE=coverage infection",
+ "rector": "rector process src",
+ "rector-check": "rector process src --dry-run"
}
}

And that is it.

If you reach here, I want to say thank you!!. You rock!!!
The post is so long and maybe not easier to read. The info we showed here can change your work drastically by adopting better coding practices. Using these tools, we not only ensure our code quality. Also improves our team-mates work-life. Our developers can focus on features, not on processes.

In the next post, I will explain how we extracted some long processes responsible for indexing our marketplace product offers into Algolia, from our legacy application to AWS Lambdas using Bref.

That’s all. Thanks for reading, and happy coding!!!

--

--