FuelPHPの例外エイリアスがキャッチできない理由を探る

以下に清書した記事をお送りします。

---

## FuelPHPのAutoloaderとエイリアスの例外キャッチに関する検証

FuelPHPでエイリアスによる例外キャッチの動作について気になる点があったため、検証を行いました。弊社ではFuelPHPフレームワークを使用しており、404エラーなどに`HttpNotFoundException`を利用しています。この例外は`Fuel\Core\HttpNotFoundException`のエイリアスとして定義されているはずですが、`Fuel\Core\HttpNotFoundException`を投げた場合に`HttpNotFoundException`でキャッチできないケースが発生しました。これを踏まえ、具体的に調査を行いました。

### 検証環境

本検証はPHP8.0の環境で実施しました。

```bash
$ php -v
PHP 8.0.30 (cli) (built: Apr 10 2024 07:34:47) ( NTS gcc x86_64 )
```

### 検証内容

エイリアスとオリジナルの例外がどのようにキャッチされるかを以下の4パターンで検証しました。

```php
<?php

/**
 * @internal
 * @coversNothing
 */
class Controller_Test extends Controller
{
    // パターン1: エイリアスのHttpNotFoundExceptionをエイリアスでキャッチ
    public function action_alias_to_alias(): Response
    {
        try {
            throw new HttpNotFoundException();
        } catch (HttpNotFoundException) {
            return Response::forge('HttpNotFoundException を HttpNotFoundException でキャッチできた！' . PHP_EOL);
        } catch (Exception) {
            return Response::forge('HttpNotFoundException を HttpNotFoundException でキャッチできなかった…' . PHP_EOL);
        }
        return Response::forge('例外をキャッチできなかった…');
    }

    // パターン2: オリジナルのFuel\Core\HttpNotFoundExceptionをオリジナルでキャッチ
    public function action_original_to_original(): Response
    {
        try {
            throw new Fuel\Core\HttpNotFoundException();
        } catch (Fuel\Core\HttpNotFoundException) {
            return Response::forge('Fuel\Core\HttpNotFoundException を Fuel\Core\HttpNotFoundException でキャッチできた！' . PHP_EOL);
        } catch (Exception) {
            return Response::forge('Fuel\Core\HttpNotFoundException を Fuel\Core\HttpNotFoundException でキャッチできなかった…' . PHP_EOL);
        }
        return Response::forge('例外をキャッチできなかった…');
    }

    // パターン3: エイリアスのHttpNotFoundExceptionをオリジナルでキャッチ
    public function action_alias_to_original(): Response
    {
        try {
            throw new HttpNotFoundException();
        } catch (Fuel\Core\HttpNotFoundException) {
            return Response::forge('HttpNotFoundException を Fuel\Core\HttpNotFoundException でキャッチできた！' . PHP_EOL);
        } catch (Exception) {
            return Response::forge('HttpNotFoundException を Fuel\Core\HttpNotFoundException でキャッチできなかった…' . PHP_EOL);
        }
        return Response::forge('例外をキャッチできなかった…');
    }

    // パターン4: オリジナルのFuel\Core\HttpNotFoundExceptionをエイリアスでキャッチ
    public function action_original_to_alias(): Response
    {
        try {
            throw new Fuel\Core\HttpNotFoundException();
        } catch (HttpNotFoundException) {
            return Response::forge('Fuel\Core\HttpNotFoundException を HttpNotFoundException でキャッチできた！' . PHP_EOL);
        } catch (Exception) {
            return Response::forge('Fuel\Core\HttpNotFoundException を HttpNotFoundException でキャッチできなかった…' . PHP_EOL);
        }
        return Response::forge('例外をキャッチできなかった…');
    }
}
```

### 実行結果

各パターンの実行結果は以下の通りです。

```bash
$ curl -k https://localhost/test/alias_to_alias
HttpNotFoundException を HttpNotFoundException でキャッチできた！
$ curl -k https://localhost/test/original_to_original
Fuel\Core\HttpNotFoundException を Fuel\Core\HttpNotFoundException でキャッチできた！
$ curl -k https://localhost/test/alias_to_original   
HttpNotFoundException を Fuel\Core\HttpNotFoundException でキャッチできた！
$ curl -k https://localhost/test/original_to_alias
Fuel\Core\HttpNotFoundException を HttpNotFoundException でキャッチできなかった…
```

この結果から、オリジナルの例外をエイリアスでキャッチすることができないことが分かりました。

### 追加検証：エイリアスの事前参照

`catch`はクラスが定義されていなくても記述可能なため、エイリアスが作成されていない可能性が考えられました。そのため、あらかじめオリジナルとエイリアスのインスタンスを生成し、オリジナルをエイリアスでキャッチする以下の検証を追加しました。

```php
// オリジナルのFuel\Core\HttpNotFoundExceptionをエイリアスでキャッチ
public function action_prepared_original_to_alias(): Response
{
    $original = new Fuel\Core\HttpNotFoundException();
    $alias = new HttpNotFoundException();
    try {
        throw $original;
    } catch (HttpNotFoundException) {
        return Response::forge('Fuel\Core\HttpNotFoundException を HttpNotFoundException でキャッチできた！' . PHP_EOL);
    } catch (Exception) {
        return Response::forge('Fuel\Core\HttpNotFoundException を HttpNotFoundException でキャッチできなかった…' . PHP_EOL);
    }
    return Response::forge('例外をキャッチできなかった…');
}
```

実行結果は以下の通りで、予想通りFuelPHPではクラスの初回参照時にエイリアスが生成されることが確認できました。

```bash
$ curl -k https://localhost/test/prepared_original_to_alias
Fuel\Core\HttpNotFoundException を HttpNotFoundException でキャッチできた！
```

### 結論

FuelPHPの例外エイリアスも、PHPの`class_alias`を利用した場合と同様に動作します。エイリアスを一度も参照していない場合は、エイリアスが生成されないためキャッチできません。エイリアスを利用する場合は、最初の参照タイミングに留意する必要があります。
FuelPHPではFuel\Coreに定義されているClassはすべてエイリアスで参照可能なため、意図的にOverrideされると困るClassを参照したい場合以外は、Overrideする内容も含めて解決してくれるエイリアスを利用することを徹底するほうが良さそうです。

---

### NOTE: 未定義の例外をキャッチに指定する場合

以下は未定義の例外クラスをキャッチに指定した場合の動作例です。

```bash
$ php -a
php > try {
php {     throw new Exception();
php { } catch (UndefinedTestException) {
php {     echo 'Catch UndefinedTestException' . PHP_EOL;
php { } catch (Exception) {
php {     echo 'Catch Exception' . PHP_EOL;
php { }
Catch Exception
```

未定義の例外をキャッチに指定してもエラーにはならず、次に一致する例外がキャッチされます。