動的プロパティの非推奨と#[AllowDynamicProperties]の付け方
宣言していないプロパティへの代入(動的プロパティの作成)は PHP 8.2 で非推奨になり、PHP 9.0 では Error になります。回避するには宣言するか、クラスに #[\AllowDynamicProperties] を付けます。
PHP 8.2 に上げたとき、いちばん大量にログが出る非推奨がこれだと思います。しかも出方が地味に嫌で、Deprecated: Creation of dynamic property Foo::$bar is deprecated が数百行、それも自分が書いていないベンダーコードから流れてきたりします。
私が担当していた古めの案件でも、最初に8.2で動かしたときはログが真っ黒になりました。ただ蓋を開けてみると、直すべきものと属性で黙らせていいものがきれいに分かれていて、方針が決まってからは案外あっさり片付いたという記憶です。今日はその分け方の話をします。
そもそも何が非推奨になったのか
クラスに宣言のないプロパティへ代入すると、警告が出るようになりました。
<?php
class User
{
private int $uid;
}
$user = new User();
$user->name = 'Foo';
Deprecated: Creation of dynamic property User::$name is deprecated in /app/a.php on line 9
ありがたいのは、typoが黙って通らなくなることです。$user->nmae = 'Foo' と書いても、8.1までは何も言わずに新しいプロパティが増えるだけでした。読み出す側で「なぜ空なんだ」と半日溶かす、あの類のバグが構造的に減ります。
注意したいのは、クラスの外から触ったときだけの話ではないことです。自分のクラスの中で $this-> に書いても同じように出ます。
<?php
class User
{
public function __construct()
{
$this->name = 'test'; // これも非推奨
}
}
new User();
「コンストラクタで代入しているから宣言したことになる」という書き方、レガシーなコードだと本当によく見ます。PHP 4系の作法をそのまま引きずったクラスですね。8.2以降はこれが全部引っかかります。
もうひとつ、警告が出るのは「そのオブジェクトにプロパティが生える瞬間」だけです。同じプロパティに二度目の代入をしても出ません。ループの中で同じ処理をしていても、オブジェクトごとに一度きり。ログの行数で影響範囲を見積もると実際より小さく見えるので、そこは注意しておきたいところです。
3つの例外パターン
全部が引っかかるわけではなく、抜け道が3つ用意されています。ここを知らないと、直さなくていいものまで直しにいってしまいます。
1つめは stdClass とそのサブクラス。内部的に #[\AllowDynamicProperties] が付いているので、いくらでも生やせます。json_decode() の戻り値や (object) キャストの結果はこれなので、そこは何も変わりません。
<?php
$o = json_decode('{"a":1}'); // stdClass
$o->b = 2; // 警告は出ない
class Bag extends stdClass {}
$bag = new Bag();
$bag->anything = 'ok'; // これも出ない
2つめは __set() を持つクラス。マジックメソッドで受け止める設計は今回の変更の対象外です。__get() と組にしておかないと読み出せないので実質は両方書くことになりますが、判定に効いているのは __set() のほうです。
3つめが本題の属性で、これは次の節で。
#[\AllowDynamicProperties]はどこに付ける?
「このクラスは動的プロパティを許す」と明示する属性です。グローバル名前空間にあるので、名前空間付きのファイルでは先頭のバックスラッシュを忘れないようにします。
<?php
namespace App\Legacy;
#[\AllowDynamicProperties]
class Config
{
private string $env = 'production';
}
$c = new Config();
$c->debug = true; // 警告なし
嬉しいのは、子クラスが自動的に継承する点です。フレームワークの基底クラスや、自社の共通抽象クラスに一発付けておけば、その配下は全部黙ります。移行作業としてはかなり効率がいい。
逆に言えば、これは「継承ツリーごと動的プロパティを許可した」ということでもあります。基底クラスに付けるのは影響範囲が広いので、私は原則として末端の具象クラスに付けるようにしていました。基底に付けるのは、どうにもならない大量のレガシーを一旦黙らせる緊急避難として、というくらいの感覚です。
それから、付けられない場所があります。readonly クラスに付けようとすると Fatal error: Cannot apply #[AllowDynamicProperties] to readonly class ... でコンパイルエラーになります。readonly クラスは動的プロパティを許さない、という定義なので当然ですね。enum も、name/value 以外のプロパティを持てない型なので、この属性を書く意味はありません(PHPStan には enum.allowDynamicProperties という専用の指摘があります)。
__set()の中で書くと、やっぱり怒られる
ここが個人的にいちばん引っかかったところです。__set() を持つクラスは対象外、という話をしましたが、その __set() の中で動的プロパティを作ると普通に非推奨です。
<?php
class User
{
public function __set(string $name, mixed $value): void
{
$this->{$name} = $value; // Creation of dynamic property User::$name is deprecated
}
}
$user = new User();
$user->name = 'test';
「マジックメソッドで受け止めて、そのままプロパティに置く」という実装、キャッシュ目的で書いてあることがよくあります。__set() を持っているから安全だと思って読み飛ばすと、ログだけが出続けます。
直し方は素直に、自前の配列に持たせることです。
<?php
class User
{
/** @var array<string, mixed> */
private array $attributes = [];
public function __set(string $name, mixed $value): void
{
$this->attributes[$name] = $value;
}
public function __get(string $name): mixed
{
return $this->attributes[$name] ?? null;
}
public function __isset(string $name): bool
{
return isset($this->attributes[$name]);
}
}
こう書くと isset() の面倒も見ることになりますが、そのぶん「何が入っているか」が1箇所に集まります。var_dump() したときに中身が見えるようになるのも、実務では地味にありがたいです。
属性で黙らせるか、宣言し直すか
判断基準は「そのプロパティが、そのクラスの一部なのか」だと思っています。
コンストラクタで $this->name = ... と書いているだけの古いクラスは、明らかにクラスの一部です。これは宣言するのが正解で、属性でごまかすのは筋が悪い。宣言してしまえば、そのあと型を付けるところまで自然につながります。
<?php
class User
{
private string $name; // 宣言する。型はあとから付けてもいい
public function __construct(string $name)
{
$this->name = $name;
}
}
一方、外部のオブジェクトに「処理済みフラグ」を貼り付けるような使い方は、そもそもクラスの一部ではありません。こういうものは属性を付けて許可するより、外側に持つほうが素直です。WeakMap を使えばオブジェクトが破棄されたときに一緒に消えてくれるので、後片付けも考えなくて済みます。
<?php
$processed = new WeakMap();
$event = new Event();
$processed[$event] = true;
if (isset($processed[$event])) {
// 処理済み
}
結局のところ、属性が正解になるのは「本当に任意のキーを受け取るのが仕様」なクラスだけです。設定オブジェクトとか、APIレスポンスをそのまま包むDTOとか。それ以外は、非推奨警告のほうが正しいことを言っている、というのが8.2を触ってからの実感です。
数百件をどう片付けるか
手作業は無理なので、静的解析に探してもらいます。PHPStan は宣言のないプロパティへの代入を検出しますし、Rector には AddAllowDynamicPropertiesAttributeRector という、対象クラスに属性を付けて回るルールがあります。名前空間を指定して効かせられるので、「ベンダー寄りの古い層だけ一括で属性を付けて、アプリ層は手で宣言する」という分け方ができます。
ベンダーのコードから出ている場合は、自分では直せません。ライブラリを上げるのが本筋ですが、それが無理なら error_reporting から E_DEPRECATED を外すことになります。ただ、これをやると自分のコードの非推奨も見えなくなるので、私はエラーハンドラ側でファイルパスを見て振り分けるほうを選びました。あまり美しくはないですが、移行期間中だけの話と割り切れば許せる範囲だと思います。
まとめ
この非推奨は、対応が面倒なだけの変更ではなくて、「このプロパティは本当にこのクラスのものか」を全部見直させられる作業です。片付けたあとにクラスを眺めると、何を持っているクラスなのかがコードから読めるようになっている。私はそこがいちばんの収穫だったと思っています。属性は逃げ道として用意されていますが、まず宣言できないか考えて、どうしても任意のキーが要るときだけ付ける。その順番を守っておけば、9.0が来ても慌てずに済むはずです。
よくある質問
Q. #[\AllowDynamicProperties] を付けるのは負けですか?
A. そうとは限りません。任意のキーを受け取ることが仕様のクラス(設定オブジェクトや外部レスポンスのラッパー)では、これが正しい表明になります。問題なのは、宣言し忘れを隠す目的で付けることです。
Q. 警告が出るのは代入のときだけですか? 未宣言プロパティの読み出しは?
A. 今回の非推奨は作成、つまり代入のときです。未宣言プロパティの読み出しは以前から Warning: Undefined property が出る挙動で、そちらは変わっていません。
Q. PHP 9.0 になると何が起きますか?
A. 非推奨警告ではなく Error になります。ログに流れているだけだった箇所が、そのまま処理を止める例外に変わるので、8.2の段階で潰しておくのが安全です。