Set up PHP QA tools and control them via make [Tutorial Part 5]
Tags: php, docker, webdev, tutorial
Published: 2022-07-01 06:37:57

<p>This article appeared first on <a href='https://www.pascallandau.com/'>https://www.pascallandau.com/</a> at <a href='https://www.pascallandau.com/blog/php-qa-tools-make-docker/'>Set up PHP QA tools and control them via make [Tutorial Part 5]</a></p>
<hr />

In the fifth part of this tutorial series on developing PHP on Docker we will **setup some PHP code quality tools** and provide a convenient way to control them via GNU make.

[![Run QA tools](https://www.pascallandau.com/img/php-qa-tools-make-docker/run-qa-tools.gif)](https://www.pascallandau.com/img/php-qa-tools-make-docker/run-qa-tools.gif)

<small>
FYI: Originally I wanted this tutorial to be a part of 
<a href="https://www.pascallandau.com/blog/ci-pipeline-docker-php-gitlab-github/">Create a CI pipeline for dockerized PHP Apps</a>
because QA checks are imho vital part of a CI setup - but it kinda grew "too big" and took a way  too much space from, well, actually setting up the CI pipelines :)
</small>


{% youtube ocM4ktjqwIg %}


**All code samples are publicly available** in my [Docker PHP Tutorial repository on Github](https://github.com/paslandau/docker-php-tutorial/).   You find the branch with the final result of this tutorial at [part-5-php-qa-tools-make-docker](https://github.com/paslandau/docker-php-tutorial/tree/part-5-php-qa-tools-make-docker).

**All published parts of the Docker PHP Tutorial** are collected under a dedicated page at [Docker PHP Tutorial](https://www.pascallandau.com/docker-php-tutorial/). The previous part was [Run Laravel 9 on Docker in 2022](https://www.pascallandau.com/blog/run-laravel-9-docker-in-2022/) and the following one is [Use git-secret to encrypt secrets in the repository](https://www.pascallandau.com/blog/git-secret-encrypt-repository-docker/).

If you want to follow along, please subscribe to the [RSS feed](https://www.pascallandau.com/feed.xml) or [via email](https://www.pascallandau.com/blog/#newsletter) to get **automatic notifications** when the next part comes out :)

<!-- generated -->
<a id='table-of-contents'> </a>
<!-- /generated -->

## Table of contents

<!-- toc -->

- [Introduction](#introduction)
  - [The QA tools](#the-qa-tools)
      - [phpcs and phpcbf](#phpcs-and-phpcbf)
      - [phpstan](#phpstan)
      - [php-parallel-lint](#php-parallel-lint)
      - [composer-require-checker](#composer-require-checker)
      - [Additional tools (out of scope)](#additional-tools-out-of-scope)
  - [QA make targets](#qa-make-targets)
      - [The `qa` target](#the-qa-target)
      - [The `execute` "function"](#the-execute-function)
      - [Parallel execution and a helper target](#parallel-execution-and-a-helper-target)
      - [Sprinkle some color on top](#sprinkle-some-color-on-top)
  - [Further updates in the codebase](#further-updates-in-the-codebase)
  - [Wrapping up](#wrapping-up)

<!-- /toc -->

<!-- generated -->
<a id='introduction'> </a>
<!-- /generated -->

## Introduction

Code quality tools ensure a **baseline of code quality** by automatically checking certain rules, e.g. **code style definitions**, proper **usage of types**, proper **declaration of dependencies**, etc. When run regularly they are a great way to enforce better code and are thus a
**perfect fit for a CI pipeline**. For this tutorial, I'm going to setup the following tools:

- [Style Checker: phpcs](#phpcs-and-phpcbf)
- [Static Analyzer: phpstan](#phpstan)
- [Code Linter: php-parallel-lint](#php-parallel-lint)
- [Dependency Checker: composer-require-checker](#composer-require-checker)

and provide convenient access through a [`qa` make target](#the-qa-target). The end result will look like this:

[![QA tool output](https://www.pascallandau.com/img/php-qa-tools-make-docker/qa-tool-output.PNG)](https://www.pascallandau.com/img/php-qa-tools-make-docker/qa-tool-output.PNG)

FYI: When we started out with using code quality tools in general, we have used  [GrumPHP](https://github.com/phpro/grumphp) - and I would still recommend it. We have only  transitioned away from it because `make` gives us a little more flexibility and control.

You can find the "final" makefile at [`.make/01-02-application-qa.mk`](https://github.com/paslandau/docker-php-tutorial/blob/part-5-php-qa-tools-make-docker/.make/01-02-application-qa.mk).

**CAUTION**: The `Makefile` is build on top of the setup that I introduced in [Docker from scratch for PHP 8.1 Applications in 2022](https://www.pascallandau.com/blog/docker-from-scratch-for-php-applications-in-2022/),  so please refer to that tutorial if anything is not clear.

```Makefile
##@ [Application: QA]

# variables
CORES?=$(shell (nproc  || sysctl -n hw.ncpu) 2> /dev/null)

# constants
 ## files
ALL_FILES=./
APP_FILES=app/
TEST_FILES=tests/

 ## bash colors
RED:=\033[0;31m
GREEN:=\033[0;32m
YELLOW:=\033[0;33m
NO_COLOR:=\033[0m

# Tool CLI config
PHPUNIT_CMD=php vendor/bin/phpunit
PHPUNIT_ARGS= -c phpunit.xml
PHPUNIT_FILES=
PHPSTAN_CMD=php vendor/bin/phpstan analyse
PHPSTAN_ARGS=--level=9
PHPSTAN_FILES=$(APP_FILES) $(TEST_FILES)
PHPCS_CMD=php vendor/bin/phpcs
PHPCS_ARGS=--parallel=$(CORES) --standard=psr12
PHPCS_FILES=$(APP_FILES)
PHPCBF_CMD=php vendor/bin/phpcbf
PHPCBF_ARGS=$(PHPCS_ARGS)
PHPCBF_FILES=$(PHPCS_FILES)
PARALLEL_LINT_CMD=php vendor/bin/parallel-lint
PARALLEL_LINT_ARGS=-j 4 --exclude vendor/ --exclude .docker --exclude .git
PARALLEL_LINT_FILES=$(ALL_FILES)
COMPOSER_REQUIRE_CHECKER_CMD=php vendor/bin/composer-require-checker
COMPOSER_REQUIRE_CHECKER_ARGS=--ignore-parse-errors

# call with NO_PROGRESS=true to hide tool progress (makes sense when invoking multiple tools together)
NO_PROGRESS?=false
ifeq ($(NO_PROGRESS),true)
	PHPSTAN_ARGS+= --no-progress
	PARALLEL_LINT_ARGS+= --no-progress
else
	PHPCS_ARGS+= -p
	PHPCBF_ARGS+= -p
endif

# Use NO_PROGRESS=false when running individual tools.
# On  NO_PROGRESS=true  the corresponding tool has no output on success
#                       apart from its runtime but it will still print 
#                       any errors that occured. 
define execute
	if [ "$(NO_PROGRESS)" = "false" ]; then \
		eval "$(EXECUTE_IN_APPLICATION_CONTAINER) $(1) $(2) $(3) $(4)"; \
	else \
		START=$$(date +%s); \
		printf "%-35s" "$@"; \
		if OUTPUT=$$(eval "$(EXECUTE_IN_APPLICATION_CONTAINER) $(1) $(2) $(3) $(4)" 2>&1); then \
			printf " $(GREEN)%-6s$(NO_COLOR)" "done"; \
			END=$$(date +%s); \
			RUNTIME=$$((END-START)) ;\
			printf " took $(YELLOW)$${RUNTIME}s$(NO_COLOR)\n"; \
		else \
			printf " $(RED)%-6s$(NO_COLOR)" "fail"; \
			END=$$(date +%s); \
			RUNTIME=$$((END-START)) ;\
			printf " took $(YELLOW)$${RUNTIME}s$(NO_COLOR)\n"; \
			echo "$$OUTPUT"; \
			printf "\n"; \
			exit 1; \
		fi; \
	fi
endef

.PHONY: test
test: ## Run all tests
	@$(EXECUTE_IN_APPLICATION_CONTAINER) $(PHPUNIT_CMD) $(PHPUNIT_ARGS) $(ARGS)

.PHONY: phplint
phplint: ## Run phplint on all files
	@$(call execute,$(PARALLEL_LINT_CMD),$(PARALLEL_LINT_ARGS),$(PARALLEL_LINT_FILES), $(ARGS))

.PHONY: phpcs
phpcs: ## Run style check on all application files
	@$(call execute,$(PHPCS_CMD),$(PHPCS_ARGS),$(PHPCS_FILES), $(ARGS))

.PHONY: phpcbf
phpcbf: ## Run style fixer on all application files
	@$(call execute,$(PHPCBF_CMD),$(PHPCBF_ARGS),$(PHPCBF_FILES), $(ARGS))

.PHONY: phpstan
phpstan:  ## Run static analyzer on all application and test files 
	@$(call execute,$(PHPSTAN_CMD),$(PHPSTAN_ARGS),$(PHPSTAN_FILES), $(ARGS))

.PHONY: composer-require-checker
composer-require-checker: ## Run dependency checker
	@$(call execute,$(COMPOSER_REQUIRE_CHECKER_CMD),$(COMPOSER_REQUIRE_CHECKER_ARGS),"", $(ARGS))

.PHONY: qa
qa: ## Run code quality tools on all files
	@"$(MAKE)" -j $(CORES) -k --no-print-directory --output-sync=target qa-exec NO_PROGRESS=true

.PHONY: qa-exec
qa-exec: phpstan \
	phplint \
	composer-require-checker \
	phpcs
```

<!-- generated -->
<a id='the-qa-tools'> </a>
<!-- /generated -->

## The QA tools

<!-- generated -->
<a id='phpcs-and-phpcbf'> </a>
<!-- /generated -->

### phpcs and phpcbf

`phpcs` is the CLI tool of the style checker [squizlabs/PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer). It also comes with `phpcbf` - a tool to automatically fix style errors.

Installation via composer:

```shell
make composer ARGS="require --dev squizlabs/php_codesniffer"
```

For now we will simply use the pre-configured ruleset for [PSR-12: Extended Coding Style](https://www.php-fig.org/psr/psr-12/). When run in the `application` container for the first time on the `app` directory via

```text
vendor/bin/phpcs --standard=PSR12 --parallel=4 -p app
```

i.e.

```text
--standard=PSR12 => use the PSR12 ruleset
--parallel=4     => run with 4 parallel processes
-p               => show the progress
```

we get the following result:

```text
root:/var/www/app# vendor/bin/phpcs --standard=PSR12 --parallel=4 -p app

FILE: /var/www/app/app/Console/Kernel.php
----------------------------------------------------------------------
FOUND 2 ERRORS AFFECTING 1 LINE
----------------------------------------------------------------------
 28 | ERROR | [x] Expected at least 1 space before "."; 0 found
 28 | ERROR | [x] Expected at least 1 space after "."; 0 found
----------------------------------------------------------------------
PHPCBF CAN FIX THE 2 MARKED SNIFF VIOLATIONS AUTOMATICALLY
----------------------------------------------------------------------


FILE: /var/www/app/app/Http/Controllers/HomeController.php
----------------------------------------------------------------------
FOUND 4 ERRORS AFFECTING 2 LINES
----------------------------------------------------------------------
 37 | ERROR | [x] Expected at least 1 space before "."; 0 found
 37 | ERROR | [x] Expected at least 1 space after "."; 0 found
 45 | ERROR | [x] Expected at least 1 space before "."; 0 found
 45 | ERROR | [x] Expected at least 1 space after "."; 0 found
----------------------------------------------------------------------
PHPCBF CAN FIX THE 4 MARKED SNIFF VIOLATIONS AUTOMATICALLY
----------------------------------------------------------------------


FILE: /var/www/app/app/Jobs/InsertInDbJob.php
-------------------------------------------------------------------------------
FOUND 1 ERROR AFFECTING 1 LINE
-------------------------------------------------------------------------------
 13 | ERROR | [x] Each imported trait must have its own "use" import statement
-------------------------------------------------------------------------------
PHPCBF CAN FIX THE 1 MARKED SNIFF VIOLATIONS AUTOMATICALLY
-------------------------------------------------------------------------------


FILE: /var/www/app/app/Models/User.php
-------------------------------------------------------------------------------
FOUND 1 ERROR AFFECTING 1 LINE
-------------------------------------------------------------------------------
 13 | ERROR | [x] Each imported trait must have its own "use" import statement
-------------------------------------------------------------------------------
PHPCBF CAN FIX THE 1 MARKED SNIFF VIOLATIONS AUTOMATICALLY
-------------------------------------------------------------------------------
```

All errors can be fixed automatically with `phpcbf`:

```text
root:/var/www/app# vendor/bin/phpcbf --standard=PSR12 --parallel=4 -p app

PHPCBF RESULT SUMMARY
-------------------------------------------------------------------------
FILE                                                     FIXED  REMAINING
-------------------------------------------------------------------------
/var/www/app/app/Console/Kernel.php                      2      0
/var/www/app/app/Http/Controllers/HomeController.php     4      0
/var/www/app/app/Jobs/InsertInDbJob.php                  1      0
/var/www/app/app/Models/User.php                         1      0
-------------------------------------------------------------------------
A TOTAL OF 8 ERRORS WERE FIXED IN 4 FILES
-------------------------------------------------------------------------

Time: 411ms; Memory: 8MB
```

and a follow-up run of `phpcs` doesn't show any more errors:

```text
root:/var/www/app# vendor/bin/phpcs --standard=PSR12 --parallel=4 -p app
.................... 20 / 20 (100%)


Time: 289ms; Memory: 8MB
```

<!-- generated -->
<a id='phpstan'> </a>
<!-- /generated -->

### phpstan

`phpstan` is the CLI tool of the static code analyzer [phpstan/phpstan](https://github.com/phpstan/phpstan) (see also the [full PHPStan documentation](https://phpstan.org/user-guide/getting-started)). It provides some default "levels" of increasing strictness to report potential bugs based on the AST of the analyzed PHP code.

Installation via composer:

```shell
make composer ARGS="require --dev phpstan/phpstan"
```

Since this is a "fresh" codebase with very little code let's go for the [highest level 9](https://phpstan.org/user-guide/rule-levels) (as of 2022-04-24) and run it in the `application` container on the `app` and `tests` directories via:

```text
vendor/bin/phpstan analyse app tests --level=9

--level=9        => use level 9
```

```text
root:/var/www/app# vendor/bin/phpstan analyse app tests --level=9
 25/25 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%

 ------ ------------------------------------------------------------------------------------------------------------------
  Line   app/Commands/SetupDbCommand.php
 ------ ------------------------------------------------------------------------------------------------------------------
  22     Method App\Commands\SetupDbCommand::getOptions() return type has no value type specified in iterable type array.
         � See: https://phpstan.org/blog/solving-phpstan-no-value-type-specified-in-iterable-type
  34     Method App\Commands\SetupDbCommand::handle() has no return type specified.
 ------ ------------------------------------------------------------------------------------------------------------------

 ------ -------------------------------------------------------------------------------------------------------
  Line   app/Http/Controllers/HomeController.php
 ------ -------------------------------------------------------------------------------------------------------
  22     Parameter #1 $jobId of class App\Jobs\InsertInDbJob constructor expects string, mixed given.
  25     Part $jobId (mixed) of encapsed string cannot be cast to string.
  35     Call to an undefined method Illuminate\Redis\Connections\Connection::lRange().
  62     Call to an undefined method Illuminate\Contracts\View\Factory|Illuminate\Contracts\View\View::with().
 ------ -------------------------------------------------------------------------------------------------------

 ------ ------------------------------------------------------------------------------------------------------------------
  Line   app/Http/Middleware/Authenticate.php
 ------ ------------------------------------------------------------------------------------------------------------------
  17     Method App\Http\Middleware\Authenticate::redirectTo() should return string|null but return statement is missing.
 ------ ------------------------------------------------------------------------------------------------------------------

 ------ ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  Line   app/Http/Middleware/RedirectIfAuthenticated.php
 ------ ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  26     Method App\Http\Middleware\RedirectIfAuthenticated::handle() should return Illuminate\Http\RedirectResponse|Illuminate\Http\Response but returns Illuminate\Http\RedirectResponse|Illuminate\Routing\Redirector.
 ------ ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

 ------ -----------------------------------------------------------------------
  Line   app/Jobs/InsertInDbJob.php
 ------ -----------------------------------------------------------------------
  22     Method App\Jobs\InsertInDbJob::handle() has no return type specified.
 ------ -----------------------------------------------------------------------

 ------ -------------------------------------------------
  Line   app/Providers/RouteServiceProvider.php
 ------ -------------------------------------------------
  36     PHPDoc tag @var above a method has no effect.
  36     PHPDoc tag @var does not specify variable name.
  60     Cannot access property $id on mixed.
 ------ -------------------------------------------------

 ------ ----------------------------------------------------------------------------------------------------------------------------------------------------------
  Line   tests/Feature/App/Http/Controllers/HomeControllerTest.php
 ------ ----------------------------------------------------------------------------------------------------------------------------------------------------------
  24     Method Tests\Feature\App\Http\Controllers\HomeControllerTest::test___invoke() has parameter $params with no value type specified in iterable type array.
         � See: https://phpstan.org/blog/solving-phpstan-no-value-type-specified-in-iterable-type
  38     Method Tests\Feature\App\Http\Controllers\HomeControllerTest::__invoke_dataProvider() return type has no value type specified in iterable type array.
         � See: https://phpstan.org/blog/solving-phpstan-no-value-type-specified-in-iterable-type
 ------ ----------------------------------------------------------------------------------------------------------------------------------------------------------

 ------ ---------------------------------------------------------------------------------------------------------------------
  Line   tests/TestCase.php
 ------ ---------------------------------------------------------------------------------------------------------------------
  68     Cannot access offset 'database' on mixed.
  71     Parameter #1 $config of method Illuminate\Database\Connectors\MySqlConnector::connect() expects array, mixed given.
 ------ ---------------------------------------------------------------------------------------------------------------------


 [ERROR] Found 16 errors
```

After fixing (or ignoring :P) all errors, we now get

```text
root:/var/www/app# vendor/bin/phpstan analyse app tests --level=9
25/25 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%

[OK] No errors
```

<!-- generated -->
<a id='php-parallel-lint'> </a>
<!-- /generated -->

### php-parallel-lint

`php-parallel-lint` is the CLI tool of the PHP code linter [php-parallel-lint/PHP-Parallel-Lint](https://github.com/php-parallel-lint/PHP-Parallel-Lint). It ensures that all PHP files are syntactically correct.

Installation via composer:

```shell
make composer ARGS="require --dev php-parallel-lint/php-parallel-lint"
```

"Parallel" is already in the name, so we run it on the full codebase `./` with 4 parallel processes  and exclude the `.git` and `vendor` directories to speed up the execution via

```text
vendor/bin/parallel-lint -j 4 --exclude .git --exclude vendor ./
```

i.e.

```text
-j 4                              => use 4 parallel processes
--exclude .git --exclude vendor   => ignore the .git/ and vendor/ directories
```

we get 

```text
root:/var/www/app# vendor/bin/parallel-lint -j 4 --exclude .git --exclude vendor ./
PHP 8.1.1 | 4 parallel jobs
............................................................ 60/61 (98 %)
.                                                            61/61 (100 %)


Checked 61 files in 0.2 seconds
No syntax error found
```

No further TODOs here.

<!-- generated -->
<a id='composer-require-checker'> </a>
<!-- /generated -->

### composer-require-checker

`composer-require-checker` is the CLI tool of the dependency checker [maglnet/ComposerRequireChecker](https://github.com/maglnet/ComposerRequireChecker). The tool ensures that the `composer.json` file contains all dependencies that are used in the codebase.

Installation via composer:

```shell
make composer ARGS="require --dev maglnet/composer-require-checker"
```

Run it via

```text
vendor/bin/composer-require-checker check
```

```text
root:/var/www/app# vendor/bin/composer-require-checker check
ComposerRequireChecker 4.0.0@baa11a4e9e5072117e3d180ef16c07036cafa4a2
The following 1 unknown symbols were found:
+---------------------------------------------+--------------------+
| Unknown Symbol                              | Guessed Dependency |
+---------------------------------------------+--------------------+
| Symfony\Component\Console\Input\InputOption |                    |
+---------------------------------------------+--------------------+
```

What's going on here?

We use `Symfony\Component\Console\Input\InputOption` in our `\App\Commands\SetupDbCommand` and the code doesn't "fail" because `InputOption` is defined in the`symfony/console` package that is a
**transitive dependency** of `laravel/framework`, see the [`laravel/framework composer.json`](https://github.com/laravel/framework/blob/5b113dad7d2c88e15b65d987ca63f03b2be43e6a/composer.json#L34) file. 

I.e. the `symfony/console` package **does actually exist** in our `vendor` directory - but since we also use it _as a first-party-dependency directly in our code_, we must declare the dependency explicitly. Otherwise, Laravel might at some point decide to drop `symfony/console` and we would be left with broken code.

To fix this, I run

```shell
make composer ARGS="require symfony/console"
```

which will update the `composer.json` file and add the dependency. Running `composer-require-checker` again will now yield no further errors.

```text
root:/var/www/app# vendor/bin/composer-require-checker check
ComposerRequireChecker 4.0.0@baa11a4e9e5072117e3d180ef16c07036cafa4a2
There were no unknown symbols found.
```

<!-- generated -->
<a id='additional-tools-out-of-scope'> </a>
<!-- /generated -->

### Additional tools (out of scope)

In general, I'm a huge fan of code quality tools and we use them quite extensively. At some  point I'll probably dedicate a whole article to go over them in detail - but for now I'm just  gonna leave a list for inspiration:

- [brianium/paratest](https://packagist.org/packages/brianium/paratest)
  - Running PhpUnit tests in parallel
  - [malukenho/mcbumpface](https://packagist.org/packages/malukenho/mcbumpface)
    - Update the versions in the `composer.json` file after an update
  - [qossmic/deptrac-shim](https://packagist.org/packages/qossmic/deptrac-shim)
    - A shim for [qossmic/deptrac](https://packagist.org/packages/qossmic/deptrac): 
      A tool to define dependency layers based on e.g. namespaces
  - [icanhazstring/composer-unused](https://packagist.org/packages/icanhazstring/composer-unused)
    - Show dependencies in the `composer.json` that are not used in the codebase
  - [roave/security-advisories](https://packagist.org/packages/roave/security-advisories)
    - Gives a warning when packages with known vulnerabilities are used 
    - Alternative: [local-php-security-checker](https://github.com/fabpot/local-php-security-checker/)

<!-- generated -->
<a id='qa-make-targets'> </a>
<!-- /generated -->

## QA make targets

You might have noticed that **all tools have their own configuration options**. Instead of remembering each of them, I'll define corresponding make targets in `.make/01-02-application-qa.mk`. The easiest way to do so would be to "hard-code" the exact commands that I ran previously, e.g.

```makefile
.PHONY: phpstan
phpstan:  ## Run static analyzer on all application and test files 
	@$(EXECUTE_IN_APPLICATION_CONTAINER) vendor/bin/phpstan analyse app tests --level=9
```

(Please refer to the [Run commands in the docker containers](https://www.pascallandau.com/blog/docker-from-scratch-for-php-applications-in-2022/#run-commands-in-the-docker-containers) section in the previous tutorial for an explanation of the `EXECUTE_IN_APPLICATION_CONTAINER`  variable).

However, this implementation is quite inflexible: What if we want to check a single file or try out other options? So let's create some variables instead:

```makefile
PHPSTAN_CMD=php vendor/bin/phpstan analyse
PHPSTAN_ARGS=--level=9
PHPSTAN_FILES=$(APP_FILES) $(TEST_FILES)

.PHONY: phpstan
phpstan: ## Run static analyzer on all application and test files 
	@$(EXECUTE_IN_APPLICATION_CONTAINER) $(PHPSTAN_CMD) $(PHPSTAN_ARGS) $(PHPSTAN_FILES) 
```

This target allows me to override the defaults and e.g. check only the file  `app/Commands/SetupDbCommand.php` with `--level=1`

```shell
make phpstan PHPSTAN_FILES=app/Commands/SetupDbCommand.php PHPSTAN_ARGS="--level=1" 
```

```text
$ make phpstan PHPSTAN_FILES=app/Commands/SetupDbCommand.php PHPSTAN_ARGS="--level=1" 
 1/1 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%


 [OK] No errors
```

The remaining tool variables can be configured in the exact same way:

```makefile
# constants
 ## files
ALL_FILES=./
APP_FILES=app/
TEST_FILES=tests/

# Tool CLI config
PHPUNIT_CMD=php vendor/bin/phpunit
PHPUNIT_ARGS= -c phpunit.xml
PHPUNIT_FILES=
PHPSTAN_CMD=php vendor/bin/phpstan analyse
PHPSTAN_ARGS=--level=9
PHPSTAN_FILES=$(APP_FILES) $(TEST_FILES)
PHPCS_CMD=php vendor/bin/phpcs
PHPCS_ARGS=--parallel=$(CORES) --standard=psr12
PHPCS_FILES=$(APP_FILES)
PHPCBF_CMD=php vendor/bin/phpcbf
PHPCBF_ARGS=$(PHPCS_ARGS)
PHPCBF_FILES=$(PHPCS_FILES)
PARALLEL_LINT_CMD=php vendor/bin/parallel-lint
PARALLEL_LINT_ARGS=-j 4 --exclude vendor/ --exclude .docker --exclude .git
PARALLEL_LINT_FILES=$(ALL_FILES)
COMPOSER_REQUIRE_CHECKER_CMD=php vendor/bin/composer-require-checker
COMPOSER_REQUIRE_CHECKER_ARGS=--ignore-parse-errors
```

<!-- generated -->
<a id='the-qa-target'> </a>
<!-- /generated -->

### The `qa` target

From a workflow perspective I usually want to **run all configured qa tools** instead of each one individually (being able to run individually is still great if a tool fails, though).

A trivial approach would be a new target that **uses all individual tool targets as preconditions**:

```makefile
.PHONY: qa
qa: phpstan \
	phplint \
	composer-require-checker \
	phpcs
```

But we can do better, because this target produces quite a noisy output:

```text
$ make qa
 25/25 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%


 [OK] No errors

PHP 8.1.1 | 4 parallel jobs
............................................................ 60/61 (98 %)
.                                                            61/61 (100 %)


Checked 61 files in 0.3 seconds
No syntax error found
ComposerRequireChecker 4.0.0@baa11a4e9e5072117e3d180ef16c07036cafa4a2
There were no unknown symbols found.
.................... 20 / 20 (100%)


Time: 576ms; Memory: 8MB
```

I'd rather have something like this:

```text
$ make qa
phplint                             done   took 1s
phpcs                               done   took 1s
phpstan                             done   took 3s
composer-require-checker            done   took 6s
```

<!-- generated -->
<a id='the-execute-function'> </a>
<!-- /generated -->

### The `execute` "function"

We'll make this work by **suppressing the tool output** and **using a user-defined `execute` make function** to format all targets nicely. 

<small>Though "function" isn't quite correct here, because it's rather a [multiline variable](https://www.gnu.org/software/make/manual/html_node/Multi_002dLine.html#Multi_002dLine) defined via `define ... endef` that is then "invoked" via the [call function](https://www.gnu.org/software/make/manual/html_node/Call-Function.html).
</small>

```makefile
# File: 01-02-application-qa.mk

# call with NO_PROGRESS=true to hide tool progress (makes sense when invoking multiple tools together)
NO_PROGRESS?=false
ifeq ($(NO_PROGRESS),true)
	PHPSTAN_ARGS+= --no-progress
endif

	
# Use NO_PROGRESS=false when running individual tools.
# On  NO_PROGRESS=true  the corresponding tool has no output on success
#                       apart from its runtime but it will still print 
#                       any errors that occured. 
define execute
	if [ "$(NO_PROGRESS)" = "false" ]; then \
		eval "$(EXECUTE_IN_APPLICATION_CONTAINER) $(1) $(2) $(3) $(4)"; \
	else \
		START=$$(date +%s); \
		printf "%-35s" "$@"; \
		if OUTPUT=$$(eval "$(EXECUTE_IN_APPLICATION_CONTAINER) $(1) $(2) $(3) $(4)" 2>&1); then \
			printf " %-6s" "done"; \
			END=$$(date +%s); \
			RUNTIME=$$((END-START)) ;\
			printf " took $${RUNTIME}s\n"; \
		else \
			printf " %-6s" "fail"; \
			END=$$(date +%s); \
			RUNTIME=$$((END-START)) ;\
			printf " took $${RUNTIME}s\n"; \
			echo "$$OUTPUT"; \
			printf "\n"; \
			exit 1; \
		fi; \
	fi
endef
```

- the `NO_PROGRESS` variable is set to `false` by default and will cause a target to be invoked
  as before, showing all its output immediately
  - if the variable is set to `true`, the target is instead invoked via `eval` and the output is
    captured in the `OUTPUT` bash variable that will only be printed if the result of the
    invocation is faulty

The tool targets are then adjusted to use the new function.

```makefile
.PHONY: phpstan
phpstan: ## Run static analyzer on all application and test files
	@$(call execute,$(PHPSTAN_CMD),$(PHPSTAN_ARGS),$(PHPSTAN_FILES),$(ARGS))
```


We can now call the `phpstan` target with `NO_PROGRESS=true` like so:

```text
$ make phpstan NO_PROGRESS=true
phpstan                             done   took 4s
```

An "error" would look likes this:

```text
$ make phpstan NO_PROGRESS=true
phpstan                             fail   took 9s
 ------ ----------------------------------------
  Line   app/Providers/RouteServiceProvider.php
 ------ ----------------------------------------
  49     Cannot access property $id on mixed.
 ------ ----------------------------------------
```

<!-- generated -->
<a id='parallel-execution-and-a-helper-target'> </a>
<!-- /generated -->

### Parallel execution and a helper target

Technically, this also already works with the `qa` target and we can even speed up the process by 
**running the tools in parallel** with the [-j flag for "Parallel Execution"](https://www.gnu.org/software/make/manual/html_node/Parallel.html)

```text
$ make -j 4 qa NO_PROGRESS=true
phpstan                            phplint                            composer-require-checker           phpcs                               done   took 5s
done   took 5s
done   took 7s
done   took 10s
```

Well... not quite what we wanted. We also need to use  [`--output-sync=target` to controll the "Output During Parallel Execution"](https://www.gnu.org/software/make/manual/html_node/Parallel-Output.html)

```text
$ make -j 4 --output-sync=target qa NO_PROGRESS=true
phpstan                             done   took 3s
phplint                             done   took 1s
composer-require-checker            done   took 5s
phpcs                               done   took 1s
```

Since this is quite a mouthful to type, we'll use a helper target `qa-exec` for running the tools and put all the inconvenient-to-type options in the final `qa` target.

```makefile
# File: 01-02-application-qa.mk
#...

# variables
CORES?=$(shell (nproc  || sysctl -n hw.ncpu) 2> /dev/null)

.PHONY: qa
qa: ## Run code quality tools on all files
	@"$(MAKE)" -j $(CORES) -k --no-print-directory --output-sync=target qa-exec NO_PROGRESS=true

.PHONY: qa-exec
qa-exec: phpstan \
	phplint \
	composer-require-checker \
	phpcs
```

For the number of parallel processes I use `nproc` (works on Linux and Windows) resp.  `sysctl -n hw.ncpu` (works on Mac) to determine the number of available cores. If you dedicate  less resources to docker you might want to hard-code this setting to a lower value (e.g. by  adding a `CORES` variable in  the [`.make/.env`](https://www.pascallandau.com/blog/docker-from-scratch-for-php-applications-in-2022/#shared-variables-make-env)  file).

<!-- generated -->
<a id='sprinkle-some-color-on-top'> </a>
<!-- /generated -->

### Sprinkle some color on top

The final piece for getting to the output mentioned in the [Introduction](#introduction) is the  bash-coloring:

[![QA tool output](https://www.pascallandau.com/img/php-qa-tools-make-docker/qa-tool-output.PNG)](https://www.pascallandau.com/img/php-qa-tools-make-docker/qa-tool-output.PNG)

To make this work, we need to understand first how colors work in bash:

> This  [coloring] can be accomplished by adding a `\e` [or `\033`] at the beginning to form an 
> escape sequence. The escape sequence for specifying color codes is `\e[COLORm`
> (`COLOR` represents our (numeric) color code in this case).

(via [Adding colors to Bash scripts](https://dev.to/ifenna__/adding-colors-to-bash-scripts-48g4))

E.g. the following script will print a green text:

```shell
printf "\033[0;32mThis text is green\033[0m"
```

So we define the required colors as variables and use them in the corresponding places in the [`execute` function](#the-execute-function):

```makefile
 ## bash colors
RED:=\033[0;31m
GREEN:=\033[0;32m
YELLOW:=\033[0;33m
NO_COLOR:=\033[0m

# ...

# Use NO_PROGRESS=false when running individual tools.
# On  NO_PROGRESS=true  the corresponding tool has no output on success
#                       apart from its runtime but it will still print 
#                       any errors that occured. 
define execute
	if [ "$(NO_PROGRESS)" = "false" ]; then \
		eval "$(EXECUTE_IN_APPLICATION_CONTAINER) $(1) $(2) $(3) $(4)"; \
	else \
		START=$$(date +%s); \
		printf "%-35s" "$@"; \
		if OUTPUT=$$(eval "$(EXECUTE_IN_APPLICATION_CONTAINER) $(1) $(2) $(3) $(4)" 2>&1); then \
			printf " $(GREEN)%-6s$(NO_COLOR)" "done"; \
			END=$$(date +%s); \
			RUNTIME=$$((END-START)) ;\
			printf " took $(YELLOW)$${RUNTIME}s$(NO_COLOR)\n"; \
		else \
			printf " $(RED)%-6s$(NO_COLOR)" "fail"; \
			END=$$(date +%s); \
			RUNTIME=$$((END-START)) ;\
			printf " took $(YELLOW)$${RUNTIME}s$(NO_COLOR)\n"; \
			echo "$$OUTPUT"; \
			printf "\n"; \
			exit 1; \
		fi; \
	fi
endef
```

Please note, that i did **not include the tests in the `qa` target**. I like to run those  separately, because our tests usually take a lot longer to execute. So in my day-to-day work I  would run `make qa` and `make test` to ensure that code quality and tests are passing:

```text
$ make qa
phplint                             done   took 1s
phpcs                               done   took 1s
composer-require-checker            done   took 14s
phpstan                             done   took 16s

$ make test
PHPUnit 9.5.19 #StandWithUkraine

.......                                                             7 / 7 (100%)

Time: 00:03.772, Memory: 28.00 MB

OK (7 tests, 13 assertions)
```

<!-- generated -->
<a id='further-updates-in-the-codebase'> </a>
<!-- /generated -->

## Further updates in the codebase

I've also cleaned up the codebase a little in branch  [part-5-php-qa-tools-make-docker](https://github.com/paslandau/docker-php-tutorial/tree/part-5-php-qa-tools-make-docker) and even though those changes have nothing to todo with "QA tools" I didn't want to leave them  unnoticed:

- removing unnecessary files (`.styleci.yml`, `package.json`, `webpack.mix.js`)
  - removing unused values from the `.env.example` file
  - run a `composer update` to get the latest Laravel version
  - add a `show-help` script to the `scripts` section of the `composer.json` file that references the 
    `Makefile` (see also [this discussion on Twitter](https://twitter.com/PascalLandau/status/1518227256648343552))
    ```json
    
    {
        "scripts": {
            "show-help": [
                "make"
            ]
        },
        "scripts-descriptions": {
            "show-help": "Display available 'make' commands (we use make instead of composer scripts)."
        }
    }
    ```
    [![Run make via composer script](https://www.pascallandau.com/img/php-qa-tools-make-docker/make-composer-scripts.gif)](https://www.pascallandau.com/img/php-qa-tools-make-docker/make-composer-scripts.gif)
  - replace `docker-compose` with `docker compose` to use [compose v2](https://docs.docker.com/compose/cli-command/)

For some reason, the last point caused some trouble because Linux and Docker Desktop for Windows  require a `-T` flag for the `exec` command to disable a TTY allocation in some cases. Whereas on  Docker Desktop for Mac  [the missing TTY lead to a cluttered output ("staircase effect")](https://github.com/moby/moby/issues/37366#issuecomment-527099456).

Thus I modified the `Makefile` to populate a `DOCKER_COMPOSE_EXEC_OPTIONS` variable based on the OS

```makefile
# Add the -T options to "docker compose exec" to avoid the 
# "panic: the handle is invalid"
# error on Windows and Linux 
# @see https://stackoverflow.com/a/70856332/413531
DOCKER_COMPOSE_EXEC_OPTIONS=-T

# OS is defined for WIN systems, so "uname" will not be executed
OS?=$(shell uname)
ifeq ($(OS),Windows_NT)
	# Windows requires the .exe extension, otherwise the entry is ignored
	# @see https://stackoverflow.com/a/60318554/413531
    SHELL := bash.exe
else ifeq ($(OS),Darwin)
    # On Mac, the -T must be omitted to avoid cluttered output
    # @see https://github.com/moby/moby/issues/37366#issuecomment-401157643
	DOCKER_COMPOSE_EXEC_OPTIONS=
endif
```

And use the variable when defining `EXECUTE_IN_*_CONTAINER` in `.make/02-00-docker.mk`

```makefile
ifeq ($(EXECUTE_IN_CONTAINER),true)
	EXECUTE_IN_ANY_CONTAINER:=$(DOCKER_COMPOSE) exec $(DOCKER_COMPOSE_EXEC_OPTIONS) --user $(APP_USER_NAME) $(DOCKER_SERVICE_NAME)
	EXECUTE_IN_APPLICATION_CONTAINER:=$(DOCKER_COMPOSE) exec $(DOCKER_COMPOSE_EXEC_OPTIONS) --user $(APP_USER_NAME) $(DOCKER_SERVICE_NAME_APPLICATION)
	EXECUTE_IN_WORKER_CONTAINER:=$(DOCKER_COMPOSE) exec $(DOCKER_COMPOSE_EXEC_OPTIONS) --user $(APP_USER_NAME) $(DOCKER_SERVICE_NAME_PHP_WORKER)
endif
```

<!-- generated -->
<a id='wrapping-up'> </a>
<!-- /generated -->

## Wrapping up

Congratulations, you made it! If some things are not completely clear by now, don't hesitate to leave a comment. You should now have a blueprint for adding code quality tools for your dockerized application and way to conveniently control them through a Makefile.

In the next part of this tutorial, we will  [set up git secret to encrypt secret values and store them directly in the git repository](https://www.pascallandau.com/blog/git-secret-encrypt-repository-docker/).

Please subscribe to the [RSS feed](https://www.pascallandau.com/feed.xml) or [via email](https://www.pascallandau.com/blog/#newsletter) to get automatic notifications when this next part comes out :)