Commit 前に PHPStan PHPUnit PHP CS Fixer を走らせる

エンジニアの柿添です。

春が終わり梅雨が到来し、
いよいよ夏本番が目の前まで来ました。

最近はとても暑くなって来たので、キャンプかBBQに行きたいですね。

夏に向けて始めようと思ったダイエットですが、私は全然進んでいません。
食欲旺盛です、お寿司やうなぎが食べたいですね。

先日は PHPカンファレンス福岡2023 にスポンサーとして参加させていただき、多くの学びがありました。
その中でも、品質について改めて考えさせられました。

そこで以下を commit 前に強制的にかけてしまおうと考えました。

  • コード整形
  • 静的解析
  • 自動テスト

PHPのコード整形

PHPのコード整形ツール(コードフォーマッタ)とは、
PHPのコードを一貫性のあるスタイルに自動的にフォーマットするツールのことを指します。
これにより、ソースコードの読みやすさが向上し、バグの発見や修正が容易になります。

弊社のエンジニアは代表の池田含めて全員例外なく JetBrains社 の PHPStorm をや JetBrains社の製品を利用しています。
command + a & command + option + l でのコード整形が癖になっているスタッフもいるのではないでしょうか。

ある程度整形されたコードにはなりますが、整形時のルールによっては git commit に差分が出てしまいます。

例えばなんでも良いのでテキストエディタでコードを開き、乱雑にコードを記載し全くの未整形で commitを打った場合はどうなるでしょうか。
不要なスペースなどが大量に発生し、非常に多くの差分が出ることで commitも汚く、PRレビューもし辛いものになります。

PHPのコード整形ツール

PHPのコード整形ツールにはどのようなものがあるかサクッと検索してみました。

  • PHP-CS-Fixer
  • PHP Beautifier
  • PHP Formatter

などがあるようです。
弊社はこの中から PHP-CS-Fixer を選択します。

PHP-CS-Fixer設置

project の docker コンテナに入り以下を叩きます。
下2つについては project の README.md に書いておく必要があるかと思います。

# 開発時初回
mkdir -p tools/php-cs-fixer
composer require --working-dir=tools/php-cs-fixer friendsofphp/php-cs-fixer

# project clone後
composer install --working-dir=tools/php-cs-fixer

# update
composer update --working-dir=tools/php-cs-fixer

整形に必要な設定ファイルも配置します。
チューニングは必要になってくるかと思いますが、一旦は以下のような設定ファイルを使います。
また PHP-CS-Fixer だけは GitHub Actions では回さず pre-commit による利用を想定しています。

touch .php-cs-fixer.dist.php
<?php

use PhpCsFixer\Config;
use PhpCsFixer\Finder;

$rules = [
    'array_indentation' => true,
    'array_syntax' => ['syntax' => 'short'],
    'binary_operator_spaces' => [
        'default' => 'single_space',
        'operators' => ['=>' => null],
    ],
    'blank_line_after_namespace' => true,
    'blank_line_after_opening_tag' => true,
    'blank_line_before_statement' => [
        'statements' => ['return'],
    ],
    'braces' => true,
    'cast_spaces' => true,
    'class_attributes_separation' => [
        'elements' => [
            'const' => 'one',
            'method' => 'one',
            'property' => 'one',
            'trait_import' => 'none',
        ],
    ],
    'class_definition' => [
        'multi_line_extends_each_single_line' => true,
        'single_item_single_line' => true,
        'single_line' => true,
    ],
    'concat_space' => [
        'spacing' => 'none',
    ],
    'constant_case' => ['case' => 'lower'],
    'declare_equal_normalize' => true,
    'elseif' => true,
    'encoding' => true,
    'full_opening_tag' => true,
    'fully_qualified_strict_types' => true, // added by Shift
    'function_declaration' => true,
    'function_typehint_space' => true,
    'general_phpdoc_tag_rename' => true,
    'heredoc_to_nowdoc' => true,
    'include' => true,
    'increment_style' => ['style' => 'post'],
    'indentation_type' => true,
    'linebreak_after_opening_tag' => true,
    'line_ending' => true,
    'lowercase_cast' => true,
    'lowercase_keywords' => true,
    'lowercase_static_reference' => true, // added from Symfony
    'magic_method_casing' => true, // added from Symfony
    'magic_constant_casing' => true,
    'method_argument_space' => [
        'on_multiline' => 'ignore',
    ],
    'multiline_whitespace_before_semicolons' => [
        'strategy' => 'no_multi_line',
    ],
    'native_function_casing' => true,
    'no_alias_functions' => true,
    'no_extra_blank_lines' => [
        'tokens' => [
            'extra',
            'throw',
            'use',
        ],
    ],
    'no_blank_lines_after_class_opening' => true,
    'no_blank_lines_after_phpdoc' => true,
    'no_closing_tag' => true,
    'no_empty_phpdoc' => true,
    'no_empty_statement' => true,
    'no_leading_import_slash' => true,
    'no_leading_namespace_whitespace' => true,
    'no_mixed_echo_print' => [
        'use' => 'echo',
    ],
    'no_multiline_whitespace_around_double_arrow' => true,
    'no_short_bool_cast' => true,
    'no_singleline_whitespace_before_semicolons' => true,
    'no_spaces_after_function_name' => true,
    'no_spaces_around_offset' => [
        'positions' => ['inside', 'outside'],
    ],
    'no_spaces_inside_parenthesis' => true,
    'no_trailing_comma_in_list_call' => true,
    'no_trailing_comma_in_singleline_array' => true,
    'no_trailing_whitespace' => true,
    'no_trailing_whitespace_in_comment' => true,
    'no_unneeded_control_parentheses' => [
        'statements' => ['break', 'clone', 'continue', 'echo_print', 'return', 'switch_case', 'yield'],
    ],
    'no_unreachable_default_argument_value' => true,
    'no_useless_return' => true,
    'no_whitespace_before_comma_in_array' => true,
    'no_whitespace_in_blank_line' => true,
    'normalize_index_brace' => true,
    'not_operator_with_successor_space' => false, // Kakizoe
    'object_operator_without_whitespace' => true,
    'ordered_imports' => ['sort_algorithm' => 'alpha'],
    'psr_autoloading' => true,
    'phpdoc_indent' => true,
    'phpdoc_inline_tag_normalizer' => true,
    'phpdoc_no_access' => true,
    'phpdoc_no_package' => false,
    'phpdoc_no_useless_inheritdoc' => true,
    'phpdoc_scalar' => true,
    'phpdoc_single_line_var_spacing' => true,
    'phpdoc_summary' => false,
    'phpdoc_to_comment' => false, // override to preserve user preference
    'phpdoc_tag_type' => true,
    'phpdoc_trim' => true,
    'phpdoc_types' => true,
    'phpdoc_var_without_name' => true,
    'self_accessor' => true,
    'short_scalar_cast' => true,
    'simplified_null_return' => false, // disabled as "risky"
    'single_blank_line_at_eof' => true,
    'single_blank_line_before_namespace' => true,
    'single_class_element_per_statement' => [
        'elements' => ['const', 'property'],
    ],
    'single_import_per_statement' => true,
    'single_line_after_imports' => true,
    'single_line_comment_style' => [
        'comment_types' => ['hash'],
    ],
    'single_quote' => true,
    'space_after_semicolon' => true,
    'standardize_not_equals' => true,
    'switch_case_semicolon_to_colon' => true,
    'switch_case_space' => true,
    'ternary_operator_spaces' => true,
    'trailing_comma_in_multiline' => ['elements' => ['arrays']],
    'trim_array_spaces' => true,
    'unary_operator_spaces' => true,
    'visibility_required' => [
        'elements' => ['method', 'property'],
    ],
    'whitespace_after_comma_in_array' => true,

    // add
    '@PhpCsFixer:risky' => true,
    'no_superfluous_phpdoc_tags' => false,
    'phpdoc_types_order' => [
        'null_adjustment' => 'always_last',
        'sort_algorithm' => 'none',
    ],
    'general_phpdoc_annotation_remove' => [
        'annotations' => ['author'],
    ],
];


$finder = Finder::create()
    ->in([
        __DIR__ . '/app',
        __DIR__ . '/config',
        __DIR__ . '/database/factories',
        __DIR__ . '/database/seeders',
        __DIR__ . '/resources',
        __DIR__ . '/routes',
        __DIR__ . '/tests',
    ])
    ->name('*.php')
    ->notName('*.blade.php')
    ->ignoreDotFiles(true)
    ->ignoreVCS(true);

return (new Config)
    ->setFinder($finder)
    ->setRules($rules)
    ->setRiskyAllowed(true)
    ->setUsingCache(true);

PHPの静的解析

静的解析とは、コードが実際に実行されることなく、そのソースコードの解析を行う手法のことを指します。
これはコンパイラによって行われる解析と似ていますが、静的解析はより深いレベルでソースコードのチェックを行います。

PHPの静的解析ツールは、構文エラー、未定義の変数、未使用の変数、未呼び出しの関数、型の不整合、スコープの問題など、様々な種類の問題を検出できます。
これらのツールは通常、コーディング規約の遵守、設計原則の遵守、バグの早期発見など、コード品質を保つために使用されます。

静的解析は、動的解析と比べて、一部のエラーや問題を早期に検出できる利点があります。

PHPの静的解析ツール

PHPのコード静的解析ツールにはどのようなものがあるかサクッと検索してみました。

  • PHPStan
  • Psalm
  • Phan
  • PHPMD
  • Exakat

などがありますが、PHPMDを色々かけていたプロジェクトは PHPStan に方向転換しました。 PHPStanにはルールレベルがあり(ChatGPTさまによると)以下のようになっております。

  • レベル0:このレベルでは、存在しないメソッドや関数、存在しない静的メソッドやクラス、存在しない定数など、最も基本的な問題を検出します。
  • レベル1:レベル0のすべての機能に加えて、このレベルでは不正な配列キー、不正な文字列オフセット、検出されないプロパティなど、可能性のあるランタイムエラーを検出します。
  • レベル2:このレベルでは、レベル1の全ての機能に加えて、存在しない変数、読み取り専用プロパティへの書き込み、空または未定義の値へのアクセスなどを検出します。
  • レベル3:このレベルでは、レベル2の全ての機能に加えて、不適切な値のパス、存在しない配列インデックス、不適切なマジックメソッドのシグニチャなどを検出します。
  • レベル4:このレベルでは、レベル3の全ての機能に加えて、間違った型のメソッドパラメータ、不正な戻り型、互換性のないメソッドシグニチャなどを検出します。
  • レベル5:このレベルでは、レベル4の全ての機能に加えて、正しい型のメソッド呼び出し、正しい型の関数呼び出し、正しい型のプロパティへのアクセスなどを検出します。
  • レベル6:このレベルでは、レベル5の全ての機能に加えて、foreachループ内の型の問題、配列操作の型の問題、比較演算子の型の問題などを検出します。
  • レベル7:このレベルでは、レベル6の全ての機能に加えて、厳格な型検査を行います。PHPStanは、呼び出されたメソッドや関数が期待する正確な型について解析します。
  • レベル8:このレベルでは、レベル7の全ての機能に加えて、一部のPHPStanの拡張機能が提供する高度な機能を有効化します。
  • レベル9:最も厳格なレベルです。レベル8の全ての機能に加えて、一部の可能性のあるバグや間違いをさらに検出します。

これらのレベルは逐次的であり、高いレベルでは低いレベルで検出されるすべての問題に加えて、さらに厳格なチェックが行われます。
開発者は自分のコードベースの品質と、どの程度の厳格さで解析を行いたいかによって適切なレベルを選ぶことができます。

PHPStan設置

project の docker コンテナに入り以下を叩きそのまま入れます。

composer require --dev phpstan/phpstan

設定ファイルも設置します。
(レベルは6で行きます)

touch phpstan.neon
parameters:
paths:
- app
  level: 6

PHPの自動テスト

PHPUnit を利用します。
非常に多くの有用な記事がネット上に転がっていますのでこちらは割愛します。

Project参加者全員に適用したい

Github Actions でやってしまうのも良いかと思います(PHPStanは次回こちらで処理するようまとめます)。
コード整形に関しては不要な commit は要らないとのことで、commit前にしたいところです。
そのため pre-commit で処理しようと思います。

pre-commit 時の問題は環境に左右されるという点がありました。
実際 Macローカルでインストール済のPHPのバージョンは、
各自 homebrewで入れていたり、asdfで入れていたりと違うと思います。

ということで pre-commit 時には project の dockerマシン内でやってしまおうと考えました。
これであればOSやバージョンに左右されません(intel,Appleシリコン問題は割愛)。

ですのでプロジェクトの構成から変更しました。

├── .githooks                       # .git hooks 配置
│   ├── pre-commit
├── .github                         # Github Actions 配置
├── infrastructure                  # Docker etc配置 インフラ関連
│   ├── docker
│   │   ├── db
│   │   ├── php
│   ... ...
├── src                             # システム本体
...

以下のように project clone 後に pre-commit が走るようにコマンドを叩いてもらいます。
こちらは README.md に書いておく必要があり、必ず行なっていただく必要があります。

# clone 後 .githooks を 参照する
$ git config --local core.hooksPath .githooks

# 元に戻す
$ git config --local core.hooksPath .git/hooks

pre-commit は 以下のように記述しました。

  • Appコンテナ起動中かどうか
  • git add された .php ファイルに対してチェックをかける
  • php -l によるチェック
  • PHP-CS-Fixerにより整形後 再度 add
  • PHPStan によるチェック
  • add されたファイルに .phpがあれば PHPUnitテスト

今後はこちらをベースに変更して行こうと思います。

#!/bin/bash

PROJECT_DIRECTORY=src
DOCKER_CONTAINER=[ここにAppコンテナ名を入れる]
SYNTAX_CHECK_RESULT=0
PHPUNIT_RESULT=0
IS_PHP_CHANGE=0

##################################################
# Display Settings
##################################################
[ "${BASH_VERSION:-}" ] && shopt -s expand_aliases
ESC=$(printf '\033')
RESET="${ESC}[0m"
BLUE="${ESC}[34m"
RED="${ESC}[31m"
GREEN="${ESC}[32m"
BG_RED="${ESC}[41m"

printf "%sRUNNING PRE-COMMIT HOOK%s\n" "${BLUE}" "${RESET}"

##################################################
# Static Analysis
##################################################
if [ "$(docker ps -q -f name=${DOCKER_CONTAINER})" ]; then

    # Container is running
    printf "%s[PASSED] Container %s is running%s" "${GREEN}" "${DOCKER_CONTAINER}" "${RESET}"

    for FILE in $(git diff-index --name-status HEAD -- | grep -E '^[AUM].*\.php$' | cut -c3-); do
        FILE_IN_DOCKER=${FILE//$PROJECT_DIRECTORY/.}
        IS_PHP_CHANGE=1

        printf "\n%sRUN php -l%s\n" "${BLUE}" "${RESET}"

        if docker exec "${DOCKER_CONTAINER}" php -l "${FILE_IN_DOCKER}"; then

            # PSR-compliant code rewriting
            printf "\n%sRUN php-cs-fixer%s\n" "${BLUE}" "${RESET}"
            docker exec "${DOCKER_CONTAINER}" ./tools/php-cs-fixer/vendor/bin/php-cs-fixer fix "${FILE_IN_DOCKER}"
            git add "${FILE}"

#            # PHPMD
#            printf "\n%sRUN php-md%s\n" "${BLUE}" "${RESET}"
#            if ! docker exec "${DOCKER_CONTAINER}" php vendor/phpmd/phpmd/src/bin/phpmd "${FILE_IN_DOCKER}" text phpmd.xml; then
#                SYNTAX_CHECK_RESULT=1
#                printf "%s%s[FAILED] Please fix phpmd check error: %s%s\n" "${BG_RED}" "${RED}" "${FILE_IN_DOCKER}" "${RESET}"
#            fi

            # PHPStan
            printf "\n%sRUN PHPStan%s\n" "${BLUE}" "${RESET}"
            if ! docker exec "${DOCKER_CONTAINER}" ./vendor/bin/phpstan --memory-limit=1G analyse -l 6 "${FILE_IN_DOCKER}"; then
                SYNTAX_CHECK_RESULT=1
                printf "%s%s[FAILED] Please fix PHPStan check error: %s%s\n" "${BG_RED}" "${RED}" "${FILE_IN_DOCKER}" "${RESET}"
            fi

        else
            printf "%s%s[FAILED] Please fix php -l check error: %s%s\n" "${BG_RED}" "${RED}" "${FILE_IN_DOCKER}" "${RESET}"
            SYNTAX_CHECK_RESULT=1
        fi
    done

    # PHPUnit
    if [ $IS_PHP_CHANGE -eq 1 ]; then
        printf "\n%sRUN PHPUnit%s\n" "${BLUE}" "${RESET}"
        if ! docker exec "${DOCKER_CONTAINER}" ./vendor/bin/phpunit; then
            PHPUNIT_RESULT=1
            printf "%s%s[FAILED] Please fix PHPUnit check error: %s%s\n" "${BG_RED}" "${RED}" "${FILE_IN_DOCKER}" "${RESET}"
        fi
    fi

else

    # Container is not running
    printf "%s[FAILED] Container %s is not running%s\n" "${RED}" "${DOCKER_CONTAINER}" "${RESET}"
    SYNTAX_CHECK_RESULT=1

fi

## SYNTAX_CHECK_RESULT
if [ $SYNTAX_CHECK_RESULT -eq 0 ] && [ $PHPUNIT_RESULT -eq 0 ]; then
    exit 0
else
    printf "\n%s[FAILED] Please correct the relevant part and re-commit.%s\n" "${BG_RED}" "${RESET}"
    exit 1
fi

まとめ

PHP-CS-Fixer, PHPStan, PHPUnitを有効活用しよう! 使えるツールは使い、自動でやれるところはやってしまって、 全員で品質をあげていきましょう! ということがこの記事で言いたかったことです。

次回はpre-commit側で処理せず Github Actions 側で PHPStan を走らせる記事を書こうと思います。 ( Snykについては神山くんが書いてくれると思います!(ぶん投げ) )

Related article

おすすめ関連記事