PHP7系で readonly なプロパティを擬似的に再現するサンプルコード

新人研修用途で書きました

# まえがき

PHP8系には、__construct() 以降上書き出来ないという素晴らしい readonly なプロパティが存在します
ただPHP7系には存在しないため、何とか擬似的にでも再現したいと言うのが今回のお話です

さすがに readonly プロパティと完全に同じ挙動を再現しようとすると非常に大変なので、「外部からプロパティを上書き不可、読み込みのみ可能」という点だけ再現します

# サンプルコード1

権限を管理するバリューオブジェクトクラス
ログインユーザーがどの権限を保持しているかを判定する責任を持つクラスです

```php
use InvalidArgumentException;
use OutOfBoundsException;

/**
 * 権限クラス
 *
 * @property-read int $value 格納されたミュータブルな値
 * @property-read bool $isAdmin 社内管理者かどうか
 * @property-read bool $isClientAdmin クライアント管理者かどうか
 * @property-read bool $isClientManager クライアントマネージャーかどうか
 * @property-read bool $isClientUser クライアント一般ユーザーかどうか
 */
class AuthId
{
    /**
     * 社内管理者
     *
     * @static
     * @var int
     */
    private const ADMIN = 1;

    /**
     * クライアント管理者
     *
     * @static
     * @var int
     */
    private const CLIENT_ADMIN = 2;
    
    /**
     * クライアントマネージャー
     *
     * @static
     * @var int
     */
    private const CLIENT_MANAGER = 3;

    /**
     * クライアント一般ユーザー
     *
     * @static
     * @var int
     */
    private const CLIENT_USER = 4;

    /**
     * @var int
     */
    private $authId;

    /**
     * @param  int  $authId
     */
    public function __construct(int $authId)
    {
        if (
            !in_array(
                $authId, 
                [
                    self::ADMIN, 
                    self::CLIENT_ADMIN, 
                    self::CLIENT_MANAGER, 
                    self::CLIENT_USER,
                ], 
                true
            ) 
        ) {
            throw new InvalidArgumentException('範囲外の auth_id が渡されました');
        }

        $this->authId = $authId;
    }

    /**
     * @return int
     */
    private function value(): int
    {
        return $this->authId;
    }

    /**
     * 社内管理者かどうか
     *
     * @return bool
     */
    private function isAdmin(): bool
    {
        return (
            $this->authId === self::ADMIN
        );
    }

    /**
     * クライアント管理者かどうか
     *
     * @return bool
     */
    private function isClientAdmin(): bool
    {
        return (
            $this->authId === self::CLIENT_ADMIN
        );
    }

    /**
     * クライアントマネージャーかどうか
     *
     * @return bool
     */
    private function isClientManager(): bool
    {
        return (
            $this->authId === self::CLIENT_MANAGER
        );
    }

    /**
     * クライアント一般ユーザーかどうか
     *
     * @return bool
     */
    private function isClientUser(): bool
    {
        return (
            $this->authId === self::CLIENT_USER
        );
    }

    /**
     * @param  string  $key
     * @return bool
     * @throws OutOfBoundsException
     */
    public function __get($key): bool
    {
        if (!method_exists($this, $key)) {
            throw new OutOfBoundsException('プロパティ ' . $key . ' は存在しません。');
        }

        return $this->{$key}();
    }

    /**
     * @param  string  $key
     * @param  mixed  $value
     * @throws OutOfBoundsException
     */
    public function __set($key, $value)
    {
        throw new OutOfBoundsException('プロパティ ' . $key . ' は存在しません。');
    }
}
```

# ポイント

## PHPDoc に @property-read を追加する 

静的解析で「擬似的にプロパティを持ってますよ、readonly でアクセス出来ますよ」というのを伝える

```php
/**
 * 権限クラス
 *
 * @property-read int $value 格納されたミュータブルな値
 * @property-read bool $isAdmin 社内管理者かどうか
 * @property-read bool $isClientAdmin クライアント管理者かどうか
 * @property-read bool $isClientManager クライアントマネージャーかどうか
 * @property-read bool $isClientUser クライアント一般ユーザーかどうか
 */
```

## 定数やメソッドは private にする（※マジックメソッド以外）

AuthId のことは AuthId クラスだけが知っていれば良いので、外部に漏れ出さないようにする

```php
    /**
     * 社内管理者かどうか
     *
     * @return bool
     */
    private function isAdmin(): bool
    {
        return (
            $this->authId === self::ADMIN
        );
    }
```

## __get() マジックメソッドを定義する

クラスに存在しないプロパティの値を取得しようとすると呼び出されるメソッドです
このメソッド経由で、private メソッドを実行して値を取得しています

存在しないメソッド名が渡された場合は、例外にします

```php
    /**
     * @param  string  $key
     * @return bool
     * @throws OutOfBoundsException
     */
    public function __get($key): bool
    {
        if (!method_exists($this, $key)) {
            throw new OutOfBoundsException('プロパティ ' . $key . ' は存在しません。');
        }

        return $this->{$key}();
    }
```

## __set() マジックメソッドを定義する

クラスに存在しないプロパティに値を格納しようとすると呼び出されるメソッドです
イミュータブルなクラスにしたいので、例外なく例外にします

```php
    /**
     * @param  string  $key
     * @param  mixed  $value
     * @throws OutOfBoundsException
     */
    public function __set($key, $value)
    {
        throw new OutOfBoundsException('プロパティ ' . $key . ' は存在しません。');
    }
```


# サンプルコード2

実際に利用するサンプル


```php
// $user->auth_id = 1;
$authId = new AuthId($user->auth_id);

// true
$authId->isAdmin;
// false
$authId->isClientAdmin;
// false
$authId->isClientManager;
// false
$authId->isClientUser;

// 存在しないプロパティにアクセスしようとしたのでOutOfBoundsException例外
$authId->isAdmin = 1;
// 存在しないプロパティから値を取得しようとしたのでOutOfBoundsException例外
$authId->isTestUser;
```

$authId は isAdmin, isClientAdmin, isClientManeger, isClientUser のどのプロパティも持っていないため、未定義プロパティにアクセスした際にマジックメソッド、__get() が呼び出されます。

## isAdmin にアクセスした場合の __get() メソッドの挙動

```php
    // $key = 'isAdmin'
    public function __get($key): bool
    {
        // AuthIdクラスに isAdmin() メソッドが存在するかどうかを判定
        if (!method_exists($this, $key)) {
            throw new OutOfBoundsException('プロパティ ' . $key . ' は存在しません。');
        }
            
        // $this->isAdmin() が呼び出される
        return $this->{$key}();
    }
```

# Laravel の Model クラスも似たような挙動をしています

Model クラスが各テーブルのカラムをプロパティとして持っているわけではなく、配列に格納した値をプロパティっぽく取得できるようにしているだけです。

User クラスの auth_id を取得しようとすると
```php
$user->auth_id;
```

Model クラスの中では、配列の auth_id キーを返却するようなイメージ。実際にプロパティを保持していない。（※イメージなのでこんなに簡単なロジックではないです）

```php
public function __get($key)
{
    // $key = 'auth_id';
    return $this->attributes[$key];
}
```

興味が出たら illuminate のコード読んでみましょう