リリースを止めずにPHP7.0から8.4へ！Rectorで実現するバージョンアップ戦略

## なぜ「リリースを止めない」ことが重要なのか

リリースが止まると、ただ作業が遅れるだけではありません。新規開発が完全にストップすると、事業に影響し、ひいては売上にも関わってくる可能性があります。特にWeb系企業では、突発的なリリースが大きな売上に繋がるケースも珍しくありません。

弊社の場合、ほとんどがtoC向けシステムで構成されており、リリースを止められるのはせいぜい1〜2日が限界です。

**そこで今回は、「とにかくリリースを止めないこと」に注力した、Rectorを活用したPHPバージョンアップ手法について解説します。**

## 既存のバージョンアップ手法の問題点

Rectorについて解説する前に、そもそも今までのPHPバージョンアップ手法について確認しましょう。  

運用と止めない前提だと「リリースブランチ」のようなとても大きいブランチにを作成して、そこにどんどん変更内容をコミットする手法が一般的です。

ただこの方法の問題点としては以下のようなものが考えられます。
- リリースブランチが大きくなりすぎることで、影響範囲が不透明に、またレビューもコード量から事実上不可能に
- 他の人がリリースした内容をマージする際に以下のような問題が発生する
  - 取り込む際にコンフリクトが発生する
  - リリースした内容をPHP8.4の内容にリファクタリングしてしまい、リリースが発生するごとに開発コストが増える

これらの問題をRectorで解決します。


## Rectorとは

https://getrector.com/

Rectorは、PHPコードを抽象構文木（AST）レベルで自動変換できるリファクタリングツールです。バージョンアップに伴う非推奨APIの置き換えやコードスタイルの一括更新などを、手作業ではなく設定したルールに沿ってCI上で自動化できます。

これにより、手動での変更漏れやブランチの長期化によるリリース遅延を防ぎ、運用を継続しながら迅速にPHPのバージョンアップを進めることが可能になります。

* AST: ソースコードを構文的に解析した中間表現。人間が書いたコードを構造として扱いやすくするための形式

## 今回やったこと

* PHP7.0からPHP8.4へのバージョンアップ
* Laravel5.4からLaravel12へのバージョンアップ

## 具体的なバージョンアップフロー

**PHP7.0環境とPHP8.4検証環境を同時並行で運用できるようにする** ことが大前提です。
そのため、リリースブランチのような大きなブランチは作成しません（ブランチの寿命は短いほどよい）。

RectorとDockerを使い、CI上でPHP7.0環境からPHP8.4環境を毎回自動生成します。
イメージとしては、JavaScriptのトランスパイルのようにPHP8.4用のコードを毎回変換して動かす、という発想です。

### PHP8.4環境の生成手順（ざっくり）

1. Dockerfile内で、PHP7.0用の`composer.json`をPHP8.4用に書き換えた上で`composer install`を実行
2. Rectorで、PHP7.0向けコードをPHP8.4向けに変換
3. 検証環境へデプロイ！

コードについては、できるだけ**PHP7.0とPHP8.4の両方で同じ挙動になるように**事前対応をこまめにリリースします。

それでもどうしても書き分けが必要な部分や、互換が保てない箇所については、**Rectorで毎回自動変換**させています。

この手順を踏めば、直前までPHP7.0で開発していたとしても、比較的スムーズにPHP8.4対応が可能です。

### 並行稼働時のCI環境

PHP7.0とPHP8.4それぞれに対して、コミットごとにPHPStanとPHPUnitを実行しています。   
どちらか一方の環境が壊れることはありませんでした。

PHP8.4に移行したタイミングでの挙動変化や破損を防ぐためにも、開発した箇所にテストコードを書くのはマストです。

## 今回利用したルール

Rectorでの自動変換において利用したルールは下記の通りです。  
極力公式ルールやOSSのルールで対応したかったのですが、所々対応できておらずカスタムルールとして自作しています。

先行してリリース可能なルールはは、どんどんリリースしてしまっていて、最後まで残ってCIで稼働していたのが下記のルールです。

### 【公式ルール】[RemovePhpVersionIdCheckRector](https://getrector.com/rule-detail/remove-php-version-id-check-rector)

```diff
-        if (PHP_VERSION_ID < 80000) {
-            openssl_free_key($private_key);
-        }
```
`if (PHP_VERSION_ID < 80000) { ... }` のような条件分岐を、指定バージョンに応じて削除してくれるルールです。

**このルールでメソッド単位の挙動変更は殆ど対応可能です**

例えば上記のように `openssl_free_key` のようにPHP8.4で非推奨になった関数を使っている場合、
`PHP_VERSION_ID` の分岐を入れておけば、PHP7.0では実行され、PHP8.4では該当コードが削除されます。

### 【公式ルール】[CoversAnnotationWithValueToAttributeRector](https://getrector.com/rule-detail/covers-annotation-with-value-to-attribute-rector) / [DataProviderAnnotationToAttributeRector](https://getrector.com/rule-detail/data-provider-annotation-to-attribute-rector)

```diff
 use PHPUnit\Framework\TestCase;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\CoversFunction;

-/**
- * @covers SomeClass
- */
+#[CoversClass(SomeClass::class)]
+#[CoversFunction('someFunction')]
 final class SomeTest extends TestCase
 {
-    /**
-     * @covers ::someFunction()
-     */
     public function test()
     {
     }
 }
```


PHPUnitでPHPDocをAttributeに変換するルールです。  
PHPUnit11以降では[PHPDocでのメタデータ記述が非推奨](https://phpunit.de/announcements/phpunit-12.html#:~:text=no%20longer%20possible.-,Metadata,-PHPUnit%2010%20introduced)になりました。
ただPHP7.0ではAttribute構文が使えないので、上記ルールを使って一括変換しています。

### 【カスタムルール】PHPUnitの`setUp()` / `tearDown()` に戻り値として `void` をつける
* **対象**: PHPUnitの`setUp()` / `tearDown()`メソッド
* **背景**: PHPUnitのバージョンによっては親クラスに`: void`が付与されているため、PHP8.4では同様の戻り値が必要です。 PHP7.0では`: void`が書けないため、RectorでPHP8.4時のみ自動で付与します

### 【カスタムルール】Exception → Throwableへの置換

```diff
-    public function report(Exception $exception)
+    public function report(\Throwable $e)
{
-        parent::report($exception);
+        parent::report($e);
}

```

* **対象**: `App\Exceptions\Handler` クラスのメソッド引数
* **背景**: Laravel 7以降、例外ハンドラの引数が`Exception`から`Throwable`に変更されました。 Laravelの[公式アップグレードガイド](https://laravel.com/docs/7.x/upgrade#symfony-5-related-upgrades)に従い、引数を一括で変換します

### 【カスタムルール】デフォルトnull引数の明示的nullable化

```diff
-    function __construct(int $hoge_id = null, string $fuga)
+    function __construct(?int $hoge_id, string $fuga)
```

* **対象**: デフォルト値が`null`のメソッド引数
* **背景**: PHP8.4では、デフォルトで`null`が設定されている引数は明示的にnullable型として定義する必要があります。
  既存の [ExplicitNullableParamTypeRector](https://getrector.com/rule-detail/explicit-nullable-param-type-rector) では一部のケースで変換が不十分だったため（後続の引数にデフォルト引数が存在しない場合はデフォルト引数nullを消すロジックがない）、引数の順序も考慮した独自ロジックを追加しています

## カスタムルールを書くためのコツ

正直なところ、情報はそこまで多くないです。
なので実際にルールを書く際は、**デバッグ中心＋他人のルールを読む**のが基本スタイルになります。

1. **他の人が同じルールを作っていないか確認する**
   大体ほしいなと思ったルールは既に作られているものです。
   車輪の再開発をしても仕方がないので、既存のルールが存在しないか確認しましょう。
   → [find-rule](https://getrector.com/find-rule)
   公式になくても、[rector-laravel](https://github.com/driftingly/rector-laravel)のように有志の方が作っているルールも存在します

2. **ASTの構造を理解する**
   Rector公式サイトでは、PHPコードをASTに変換して可視化できるツールが提供されています。  
   自分のコードがどんなノード構成になっているか、まずはこれで確認してみましょう。  
   → [ASTに変換して可視化できる公式ツール](https://getrector.com/ast)

3. **Node検索や置き換えの仕組みを知る**
   Rectorは、親ノードの検索を直接はサポートしていません。  
   その代わりに**Scoped Traverse**という機構で、ノードの探索を行います。  
   `NodeFinder` API や `NodeVisitor` でスコープを意識した処理を行いましょう。  
   → [スコープ付きトラバースについての公式記事](https://getrector.com/blog/rector-017-using-scoped-traverse)

4. **公式ルールの構造に従ってカスタムルールを作成する**  
   既存ルールのコードを参考にして、自分のプロジェクト要件に合わせたクラスを作成します。  
   詳しくは公式ドキュメントを参考にしてください。  
   → [カスタムルールの作成についての公式記事](https://getrector.com/documentation/custom-rule)

## おまけ

実は、コロプラさんでも同じような記事があって、内容としては二番煎じだったりするので、こちらの記事をよければどうぞ。（この記事がきっかけでRectorを知りました）

[Rectorではじめる "運用を止めない" PHPアップグレード](https://speakerdeck.com/colopl/rector-dehazimeru-yun-yong-wozhi-menai-php-atupuguredo)

コロプラさんの記事では、PHP8.0からの曖昧比較（`==`）の挙動変更に対応するためPHPの独自拡張を作っていますが、小規模プロダクトなら最初から厳密比較（`===`）に置き換えておく方が早く、曖昧比較の挙動変更に関してはこちらで対応しました。

この辺の「どこまで自動化するか」「どこまで書き換えるか」はプロダクトの規模や実装次第で、**この柔軟に手段を入れ替えられるかどうかが、エンジニアの腕の見せどころ**かなと思いました。