universalObjectCratesClassesとは何か

PHPStanを使うとオブジェクトの未定義プロパティへのアクセスを警告できて便利だが、必ずしもそうしたくないことがある。


## プロパティのオーバーロード

PHPは[オーバーロード](https://www.php.net/manual/ja/language.oop5.overloading.php)と呼ばれる機能でメソッドやプロパティの動的アクセスを提供できる。

https://www.php.net/manual/ja/language.oop5.overloading.php

:::note warn
オブジェクト指向用語の『[オーバーロード](https://ja.wikipedia.org/wiki/%E5%A4%9A%E9%87%8D%E5%AE%9A%E7%BE%A9)』の一般的な用法とは異なるので注意。
ただしPHPでもこの機能を利用して挙動を再現することは… できなくはない。
:::

プロパティに関しては、たとえば以下のように実装できる。

```php
<?php

class Row
{
    public function __construct(
        private array $row
    )
    {
    }

    public function __get(string $name)
    {
        return $this->row[$name];
    }
}

$row = new Row($stmt->fetch());
// $row = new Row(['id' => 42, 'name' => 'hoge', 'birthday' => '2112-09-03']);

var_dump([$row->id, $row->name, $row->birthday]);
// => array(3) {
//      [0]=> int(42)
//      [1]=> string(4) "hoge"
//      [2]=> string(10) "2112-09-03"
//    }
```

https://php-play.dev/?c=DwfgDgFmBQ0MYBsCGBnFACASgewO7QG9p0T0wBXAIwQEs50AzcgOzgBcbtn0B9HuLijYAncuwAUxUtLDCaANyRsApuiTDhSAJ7oAJMLxSSASiPoi0gL6wZVWvSasOXXjwDmytuKFzmbvcxIALbKptIW0qTCnuTC3LpsEDQoALQAfAa4ANq6gSEAugDcZtbW0AD05XqZ6AC86MzKuFh44rpCQWzpDJ5wEOLGxsX6eHUNTS244lkA5DQAJjN1aegALABMADToM3nKS7UrMxDYHjPbM5Q0wonz2gdH6wCMT%2BspAAwAnB8AzDP5Q1gimEPHm5CCYGmZhGuHSC020My6T2COkMPSVxuEDuWgRAOKQA&v=8.3&f=console

このコードの弱点は、実際にはアクセス可能であるに拘らずPHPStanは未定義アクセスだと警告することだ。

![スクリーンショット 2024-01-30 5.56.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4121/db9309c6-50a4-1902-3296-cda576111462.png)

https://phpstan.org/r/84f4011d-81ab-47e0-8bf4-9ccd4b96831b

:::note warn
`$this->row`のような配列ではなく、プロパティを定義せずに`$this->id`などに未定義のまま代入することもできる。ただしこれは[PHP 8.2で非推奨になり](https://wiki.php.net/rfc/deprecate_dynamic_properties)、将来[PHP 9.0でエラーになる](https://wiki.php.net/rfc/undefined_property_error_promotion)ことが決まっている。
:::

:::note
クラス定義に`#[AllowDynamicProperties]`アトリビュートを追加することでエラーは回避できるが、配列だろうと動的プロパティだろうと現在のところ静的解析の積極的な対象とは捉えられていないので、どちらでも大して変わらないと考えてよい。
:::

## 対応策1: PHPDoc `@property`

もし一種類のデータ構造とクラス定義を一対一対応できるなら、PHPDocに `@property` と書くことでPHPStanは定義済みのプロパティと同様に扱える。

```php
/**
 * @property int $id
 * @property string $name
 * @property string $birthday
 */
class Person
{
    /**
     * @param array{id: int, name: string, birthday: string} $row
     */
    public function __construct(
        private array $row,
    )
    {
    }

    public function __get(string $name): mixed
    {
        return $this->row[$name];
    }
}
```

https://phpstan.org/r/0187fbb4-8dde-4d15-b481-4a85da40a7b0


そもそもこれは現代ではあまり有効な手段ではない。プロパティの遅延フェッチのようなものを実装したいのでなければ、PHP 8以降ではこのように書いた方が簡単だからだ。

```php
readonly class Person
{
    public function __construct(
        public int $id,
        public string $name,
        public string $birthday,
    )
    {
    }
}

// $row = new Row($stmt->fetch());
$row = new Person(...['id' => 42, 'name' => 'hoge', 'birthday' => '2112-09-03']);
```

https://php-play.dev/?c=DwfgDgFmBQ0E4FMCGATA9gOwDYE8AEAxlkgM4l4AKCcJm0A3tHs3gPQBU7TLe7eAAmCRwkAWzzCROegEsUALjwyMAFwA0eDGISKSKuMoDmGgEYy4KiCiQ5d%2BowF88AEjhoA7txbtWXvGABXEywZAjwAMwCMAhUZTDwAfQSCTD04AJiACj8eQODQpVUXOTUcljyQsLSjFy1RBFKeXKDKvGqMQxczCysbRpYASj9GHgdoMehWVhc3dzwAXk0EOYAlD0znPVEVAFoAPnCEFQIITIGBgG5oVw8FpbmqGkxMgDo3gG0AcjlPhb28AAsACYNJ86ghfvN-p8IGhDBDQd1LNYcJDoUCAIwYoE7AAMAE48QBmT4AXUusAAbsIEigAqIwJl3n4bu59iUWbN9uD%2BsxWfskb0cKVyVcgA&v=8.3&f=console

PHPDocのような胡乱なものは必要ではない。

## ユニバーサルオブジェクトクレート

ここからが本題だ。そもそも今回提示したような『クラスで配列を内包して`__get()`経由でプロパティとして返す』という実装と『**一種類のデータ構造とクラス定義を一対一対応させる**』というユースケースは合致していない。

このような実装パターンが用いられるのは以下のような場合だろう。

 * データベースから動的にフェッチした行
 * ヘッドレスCMSのユーザーカスタムプロパティ
 * その他APIレスポンスの動的な要素

このようなデータは、典型的には生の配列として返すか、何らかのオブジェクトでラップしてプロパティアクセスを提供することになるだろう。

PsalmやPHPStanのような静的解析ツールは、このような未定義プロパティへのアクセスを前提としたクラスを**ユニバーサルオブジェクトクレート**(universal object crates)と呼んでいる。PHP組み込みクラスの[`stdClass`](https://www.php.net/stdClass)や[`SimpleXMLElement`](https://www.php.net/SimpleXMLElement)が典型例であり、PHPStanとPsalmの両方が初期設定でユニバーサルオブジェクトクレートとして認識している。

さて、特定のオブジェクトがユニバーサルオブジェクトクレートであるということは、PHPStanではプロジェクトの設定ファイルに以下のように記述する。

```yaml:phpstan.neon
parameters:
    universalObjectCratesClasses:
        - Cake\Datasource\EntityInterface
        - Contentful\Delivery\Resource\Entry
        - Dibi\Row
        - Ratchet\ConnectionInterface
        - Phactory\Sql\Row
        - SolrObject
```

[Psalmであれば](https://psalm.dev/docs/running_psalm/configuration/#universalobjectcrates)こうなる。

```xml
<universalObjectCrates>
	<class name="Cake\Datasource\EntityInterface" />
	<class name="Contentful\Delivery\Resource\Entry" />
	<class name="Dibi\Row" />
	<class name="Ratchet\ConnectionInterface" />
	<class name="Phactory\Sql\Row" />
	<class name="SolrObject" />
</universalObjectCrates>
```

これで、これらのクラスに対してプロパティアクセスしても怒られることはなくなる。`ignoreErrors`にいちいち追加するなどという、愚かしいことはしなくてもよい。*世界は救われた。*

## ユニバーサルオブジェクトクレートは使うべきか？

あなたが単なるフレームワークやライブラリの一般ユーザーなら、話はここでおしまい。

ただ、あなたがこのようなクラスを設計し提供する立場なら、本当にそんなことをすべきかは一考の余地はあるだろう。

`__get()`のようなマジックメソッド(オーバーロード)や動的プロパティのセットはPHPオブジェクトの動的な性質を高められるが、逆にいえば静的解析の放棄に他ならない。実装方法によっては、オプショナルなプロパティと単なるプロパティのタイプミスの区別はつけられないだろう。

:::note
個人的な意見では、データ構造に対応するクラスを用意できないような動的な要素やカスタムプロパティのようなものはオーバーロードプロパティ(`__get()`)経由ではなく、メソッド経由でのアクセスを提供するか、あるいは[`ArrayAccess::offsetGet()`](https://www.php.net/manual/ja/arrayaccess.offsetget.php)経由でのアクセスを提供した方がましなのではなかろうか。
:::

これは「自分たちがどうしたいか」「ユーザーや開発者にどうさせたいか」という設計の問題なので、それぞれの良し悪しのようなものは各自で考えてもらいたい。

## あとがき

`universalObjectCratesClasses`について、実はPHPStanの設定リファレンスにはちゃんと書いてあるのだが(それも割と上の方)、PHPStanを使っている各位はあまり説明書を読まずに使うタイプだということがわかる。

https://phpstan.org/config-reference#universal-object-crates

……という記事を一年くらい前にも書いたつもりだったが、特に宣伝とかしていなかったので、この三ヶ月くらい説明する度に初耳だと言われるので改めて書いた次第。

https://scrapbox.io/php/Universal_object_crate