1. ホーム
  2. PHP

【PHP】マジックメソッド __get・__set・__call の使い方|存在しないプロパティやメソッドへのアクセスを処理する

Share

PHP のクラスに定義していないプロパティを読むと Warning: Undefined property が出ますし、存在しないメソッドを呼べば Call to undefined method で止まります。ところが Laravel などのフレームワークでは、どこにも書かれていないはずの $user->name が読めたり、getName() というメソッドが定義されていないのに動いたりします。これを支えているのが マジックメソッド、なかでも __get や __call によるオーバーロードです。この記事では、プロパティとメソッドのオーバーロードに関わる6つのマジックメソッドについて、呼ばれる条件・実際の書き方・ハマりどころ・使うべきか避けるべきかの判断基準まで解説します。

マジックメソッドとは何か

マジックメソッドは、__(アンダースコア2つ)で始まる PHP の予約されたメソッド名のことです。自分で $obj->__get('name') のように呼ぶためのものではなく、特定の状況になったときに PHP が自動的に呼び出します。インスタンスを生成したときの __construct() や、オブジェクトを文字列として扱ったときの __toString() もマジックメソッドの仲間です。

この記事で扱うのは、そのうちプロパティとメソッドのオーバーロードに関わる6つです。PHP マニュアルでいうオーバーロードとは、Java や C++ のような「同じ名前で引数違いのメソッドを複数定義すること」ではなく、アクセスできないプロパティやメソッドへのアクセスを、実行時に動的に処理する仕組みを指します。名前が同じでも意味が違うので注意してください。

マジックメソッド呼ばれるタイミング
__get( string $name ): mixedアクセスできないプロパティを読み取ろうとしたとき
__set( string $name, mixed $value ): voidアクセスできないプロパティに書き込もうとしたとき
__isset( string $name ): boolアクセスできないプロパティに isset() / empty() を使ったとき
__unset( string $name ): voidアクセスできないプロパティに unset() を使ったとき
__call( string $name, array $arguments ): mixedアクセスできないインスタンスメソッドを呼んだとき
__callStatic( string $name, array $arguments ): mixedアクセスできない静的メソッドを呼んだとき

$name には、アクセスしようとしたプロパティ名やメソッド名が文字列で渡されます。__call / __callStatic の $arguments には、呼び出しに使われた引数が配列で入ります。表にある戻り値の型は PHP が決めているもので、型宣言を書く場合はこのとおりにしなければなりません(後述します)。

「アクセスできない」とはどういう状態か

すべての出発点になるのがこの条件です。マジックメソッドが呼ばれる「アクセスできない(inaccessible)」とは、次の2つの状態を指します。ひとつはそもそも定義されていないこと、もうひとつはクラスの外など、権限のないスコープから private / protected にアクセスしたことです。

User.php
<?php
class User
{
    public string    $name     = 'たろう';
    protected int    $age      = 20;
    private string   $password = 'secret';

    public function __get(string $name): mixed
    {
        return "__get が受け取った名前: {$name}";
    }

    public function showPassword(): void
    {
        // クラスの中からは普通にアクセスできるので __get は呼ばれない
        echo $this->password, PHP_EOL;
    }
}

$user = new User();

echo $user->name, PHP_EOL;      // たろう          ← public なので __get は呼ばれない
echo $user->password, PHP_EOL;  // __get が受け取った名前: password
echo $user->age, PHP_EOL;       // __get が受け取った名前: age
echo $user->nickname, PHP_EOL;  // __get が受け取った名前: nickname(未定義)

$user->showPassword();          // secret

ここで押さえておきたいのは、public で定義済みのプロパティにアクセスするときは __get が呼ばれないという点です。通常のプロパティアクセスが成立する場面では、PHP はマジックメソッドを一切見にいきません。「__get を書いたのに呼ばれない」というトラブルの原因は、たいていこれです。

逆に、private なプロパティを外から読もうとしたときは Cannot access private property のエラーにならず、__get が呼ばれます。__get を定義した時点で、private の壁は「エラーになる壁」から「__get に迂回する壁」へ変わる、と考えると分かりやすいはずです。

__get と __set で内部の配列をプロパティのように扱う

典型的なユースケースが、データを配列で持っているクラスに、プロパティ風のアクセスを生やすというものです。設定クラスを例にします。実体は $items という private な配列ですが、外からは $config->siteName と書けるようにします。

Config.php
<?php
class Config
{
    private array $items;

    public function __construct(array $items = [])
    {
        $this->items = $items;
    }

    // 読み取り:echo $config->siteName;
    public function __get(string $name): mixed
    {
        if (!array_key_exists($name, $this->items)) {
            throw new OutOfRangeException("設定が存在しません: {$name}");
        }
        return $this->items[$name];
    }

    // 書き込み:$config->siteName = 'webool';
    public function __set(string $name, mixed $value): void
    {
        $this->items[$name] = $value;
    }

    // isset() / empty()
    public function __isset(string $name): bool
    {
        return isset($this->items[$name]);
    }

    // unset()
    public function __unset(string $name): void
    {
        unset($this->items[$name]);
    }
}
index.php
<?php
$config = new Config([
    'siteName' => 'webool',
    'siteUrl'  => 'https://webool.net',
    'debug'    => false,
]);

echo $config->siteName, PHP_EOL;      // webool      ← __get
$config->theme = 'dark';              //             ← __set
echo $config->theme, PHP_EOL;         // dark

var_dump(isset($config->debug));      // bool(true)  ← __isset
var_dump(isset($config->timezone));   // bool(false)

unset($config->theme);                //             ← __unset
var_dump(isset($config->theme));      // bool(false)

echo $config->timezone, PHP_EOL;
// Fatal error: Uncaught OutOfRangeException: 設定が存在しません: timezone

ポイントは __get の中でキーの存在を確かめて例外を投げているところです。単に return $this->items[$name] ?? null; と書くと、$config->siteNmae のようなタイポが黙って null になり、原因の分かりにくいバグになります。プロパティ風のアクセスを提供するなら、存在しないキーは例外で知らせるのが安全です。

__set の第2引数 $value には代入しようとした値が渡されます。戻り値は使われないので void です。ここでバリデーションを挟んだり、読み取り専用にしたい場合は例外を投げたりできるのが、単なる public な配列プロパティにはない利点です。

__isset を書かないと isset() が常に false になる

上の例で __isset と __unset も実装しているのには理由があります。isset() と empty() は __get を経由しません。__get だけを実装して __isset を省くと、次のようになります。

no-isset.php
<?php
class Config
{
    private array $items = ['siteName' => 'webool'];

    // __get だけを実装している
    public function __get(string $name): mixed
    {
        return $this->items[$name] ?? null;
    }
}

$config = new Config();

echo $config->siteName, PHP_EOL;       // webool     ← 読み取りはできる
var_dump(isset($config->siteName));    // bool(false) ← 値はあるのに false
var_dump(empty($config->siteName));    // bool(true)  ← 値はあるのに true

値を読めているのに isset() が false を返すという、いちばん気付きにくい状態です。isset() は __isset を探し、見つからなければ「そんなプロパティは無い」と判断して false を返します。empty() も同じ判定を使うので、常に「空」とみなされます。__get を実装したら __isset もセットで実装する、と覚えてください。書き込み側も同様で、__set を実装するなら削除のための __unset も用意しておくと、四隅がそろって挙動が素直になります。

なお empty() の判定は2段階です。まず __isset が呼ばれ、それが true を返したときだけ、値が空かどうかを確かめるために __get が呼ばれます。__isset が false を返した時点で empty() は true に確定し、__get は呼ばれません。

__call で getName() / setName() をまとめて引き受ける

プロパティと同じことがメソッドでもできます。__call は定義されていないインスタンスメソッドが呼ばれたときに実行され、メソッド名と引数の配列を受け取ります。属性ごとに getName() / setName() / getEmail() …… と機械的なアクセサを並べる代わりに、1か所でまとめて処理できます。

Model.php
<?php
class Model
{
    public function __construct(private array $attributes = []) {}

    public function __call(string $name, array $arguments): mixed
    {
        $action = substr($name, 0, 3);         // 'get' または 'set'
        $key    = lcfirst(substr($name, 3));   // 'Name' => 'name'

        if ($action === 'get' && array_key_exists($key, $this->attributes)) {
            return $this->attributes[$key];
        }

        if ($action === 'set' && array_key_exists($key, $this->attributes)) {
            $this->attributes[$key] = $arguments[0] ?? null;
            return $this;   // メソッドチェーンできるように $this を返す
        }

        // 処理できない呼び出しは必ず例外にする
        throw new BadMethodCallException(
            '定義されていないメソッドです: ' . static::class . "::{$name}()"
        );
    }
}
index.php
<?php
$user = new Model([
    'name'  => 'たろう',
    'email' => 'taro@example.com',
]);

echo $user->getName(), PHP_EOL;     // たろう

$user->setName('はなこ')->setEmail('hanako@example.com');

echo $user->getName(), PHP_EOL;     // はなこ
echo $user->getEmail(), PHP_EOL;    // hanako@example.com

$user->getAddress();
// Fatal error: Uncaught BadMethodCallException:
// 定義されていないメソッドです: Model::getAddress()

いちばん大事なのは最後の throw です。__call を実装すると、そのクラスは「どんな名前のメソッド呼び出しも受け付ける」状態になります。何も投げずに null を返す実装にすると、getAddress() のような存在しない呼び出しが静かに通ってしまい、本来なら Call to undefined method で即座に気付けたはずの間違いを見逃します。処理できない呼び出しは BadMethodCallException で明示的に落とすのが鉄則です。

$arguments には引数が呼び出し順の配列で入ります。名前付き引数で $user->setName(value: 'はなこ') のように呼ばれた場合は、その名前が配列のキーになります(PHP 8.0 以降)。$arguments[0] 決め打ちで書いていると取りこぼすので、名前付き引数を許容するなら注意してください。

ちなみに __call はスコープ外から private / protected なメソッドを呼んだときにも呼ばれます。プロパティと同じルールです。クラスの外から $model->internalMethod() と書くと、アクセス権のエラーではなく __call に届きます。

__callStatic で静的メソッドの呼び出しを受け取る

ClassName::method() という静的な呼び出しを引き受けるのが __callStatic です。役割は __call と同じですが、public static として宣言する必要があります。ログレベルをメソッド名で表す例を見てみます。

Logger.php
<?php
class Logger
{
    private const LEVELS = ['info', 'warning', 'error'];

    public static function __callStatic(string $name, array $arguments): mixed
    {
        if (!in_array($name, self::LEVELS, true)) {
            throw new BadMethodCallException("不明なログレベルです: {$name}");
        }

        printf('[%s] %s' . PHP_EOL, strtoupper($name), $arguments[0] ?? '');
        return null;
    }
}

Logger::info('処理を開始しました');    // [INFO] 処理を開始しました
Logger::error('保存に失敗しました');   // [ERROR] 保存に失敗しました

Logger::debug('デバッグ');
// Fatal error: Uncaught BadMethodCallException: 不明なログレベルです: debug

ここでも、受け付ける名前を LEVELS という定数で限定し、それ以外は例外にしています。受け付ける範囲をコードの中に明示しておくと、マジックメソッドの「何でも通ってしまう」という性質をある程度抑えられます。

宣言のしかたに決まりがある

マジックメソッドは PHP が呼び出すものなので、宣言のしかたにも決まりがあります。まずすべて public にしなければなりません。private function __get() のように書くと、Warning: The magic method Config::__get() must have public visibility という警告が出ます。

次に __callStatic だけは static が必須です。付け忘れると Fatal error: Method Logger::__callStatic() must be static になり、そもそもファイルを読み込んだ時点で止まります。逆に __get や __call は static にできません。

戻り値の型は、書くなら表のとおりでなければなりません。たとえば public function __isset(string $name): string と書くと Fatal error: __isset(): Return type must be bool when declared になります。型宣言そのものは省略できますが、マニュアルどおりの型を明示しておくほうが安全です。__get と __call の mixed、__isset の bool、__set と __unset の void を覚えておけば困りません。

思ったとおりに呼ばれないとき

マジックメソッドは「普通のアクセスが失敗したときの受け皿」なので、呼ばれる・呼ばれないの境目が直感に反することがあります。実際に引っかかりやすいものを4つ挙げます。

public なプロパティを定義していると __get が呼ばれない

冒頭でも触れたとおり、これが最頻出です。__get で一括処理するつもりのプロパティを、うっかりクラスに public array $items = []; のように宣言していると、通常のアクセスが成立するため __get も __set も出番がありません。オーバーロードで扱いたいデータは private または protected にするのが原則です。

面白いのは、public なプロパティでも unset() するとその後は __get が呼ばれるようになる点です。プロパティが「存在しない」状態になるためで、条件が「アクセスできないかどうか」であることがよく分かる挙動です。

unset-public.php
<?php
class Sample
{
    public string $name = 'たろう';

    public function __get(string $name): mixed
    {
        return "__get: {$name}";
    }
}

$sample = new Sample();
echo $sample->name, PHP_EOL;   // たろう      ← 通常のプロパティアクセス

unset($sample->name);          // プロパティ自体を消す
echo $sample->name, PHP_EOL;   // __get: name ← 今度は __get が呼ばれる

配列の要素に直接代入しても反映されない

__get で配列を返すクラスに対して $obj->tags[] = 'php'; や $obj->tags['key'] = 1; と書くと、値が変わりません。これは間接的な変更(indirect modification)と呼ばれる状況で、通知が出ます。

indirect.php
<?php
class Bag
{
    private array $items = ['tags' => ['php']];

    public function __get(string $name): mixed { return $this->items[$name] ?? null; }
    public function __set(string $name, mixed $value): void { $this->items[$name] = $value; }
}

$bag = new Bag();
$bag->tags[] = 'wordpress';
// Notice: Indirect modification of overloaded property Bag::$tags has no effect

print_r($bag->tags);   // Array ( [0] => php )  ← 増えていない

原因は、__get が返すのが配列のコピー(値)であって、内部の配列そのものではないことです。返ってきた一時的な値に要素を足しても、クラスの中の $items には届きません。__set は「プロパティ全体への代入」でしか呼ばれないので、要素への代入では出番がないわけです。

いちばん分かりやすい対処は、いったん変数に取り出して、書き換えてから代入し直すことです。__get で読み、__set で書くという流れが明示されるので、読み手も迷いません。

indirect-fix.php
<?php
$tags   = $bag->tags;        // __get でコピーを受け取る
$tags[] = 'wordpress';       // コピーを書き換える
$bag->tags = $tags;          // __set で戻す

print_r($bag->tags);         // Array ( [0] => php [1] => wordpress )

どうしても $bag->tags[] = 'wordpress'; という書き方を通したいなら、__get を参照を返す関数として宣言する方法があります。メソッド名の前に & を付けて public function &__get(string $name): mixed とし、内部の配列要素をそのまま return します。ただし、返した参照を通じて外から中身を書き換えられるようになるため、__set のバリデーションを素通りするなど、カプセル化の利点を手放すことになります。使うなら影響範囲を理解したうえで、というレベルの手段です。

__get の中で同じプロパティを読んでも __get は呼ばれない

__get の中でうっかり $this->$name と書いてしまうことがあります。素直に考えれば無限再帰になりそうですが、PHP には実行中のマジックメソッドを再帰的に呼ばないためのガードがあり、そうはなりません。代わりに、通常どおり「未定義プロパティ」として扱われます。

recursion.php
<?php
class Sample
{
    public function __get(string $name): mixed
    {
        return $this->$name;   // 同じプロパティを読み直している
    }
}

$sample = new Sample();
var_dump($sample->foo);
// Warning: Undefined property: Sample::$foo
// NULL

フリーズはしないものの、null と警告が返るだけなので、原因の分かりにくい不具合になります。__get の中では、データを保持している private プロパティ(上の例なら $this->items[$name])を直接参照するように書いてください。

静的プロパティには効かない

プロパティのオーバーロードが働くのはオブジェクトのコンテキストだけです。メソッドには __callStatic がありますが、プロパティに対応するものはありません。Config::$siteName のように未定義の静的プロパティにアクセスしても __get は呼ばれず、Fatal error: Uncaught Error: Access to undeclared static property Config::$siteName になります。

便利さと引き換えに失うもの

ここまで見てきたとおり、マジックメソッドを使えばコードの記述量はかなり減らせます。それでも多用は避けるべきとされているのは、静的に読めない挙動が増えるからです。

まず、IDE の補完も静的解析も効かなくなります。$config-> まで打っても候補は出ませんし、PHPStan や Psalm のようなツールも「そんなプロパティは無い」としか言えません。どんな名前でも受け付けてしまうので、$config->siteNmae のようなタイポが実行時までエラーにならないという問題もあります。前述したように、__get や __call の中で例外を投げて自衛するのが最低限の対策です。

速度面の不利もあります。__get 経由のアクセスは、宣言済みプロパティの読み取りと違ってメソッド呼び出しが1回挟まるため、明確に遅くなります。1回ごとの差は小さいので普通のリクエスト処理で問題になることは少ないものの、ループの中で何十万回も読むような場面では避けるのが無難です。

判断の目安はシンプルで、プロパティ名やメソッド名が事前に分かっているなら、素直に定義することです。扱う属性が数個に決まっているなら、__call でアクセサを作るより getName() を手で書いたほうが、補完も型チェックも効いて結局読みやすくなります。マジックメソッドが向くのは、データベースの列やコンフィグの項目のように、どんな名前が来るかコードを書く時点では決められない場面です。実装するときは、その理由をクラスのコメントに一言残しておくと、後から読む人が迷いません。

まとめ

マジックメソッドは __ で始まる予約されたメソッド名で、特定の状況で PHP が自動的に呼び出します。プロパティのオーバーロードは __get(読み取り)・__set(書き込み)・__isset(isset() / empty())・__unset(unset())の4つ、メソッドのオーバーロードは __call(インスタンスメソッド)と __callStatic(静的メソッド)の2つです。呼ばれる条件は共通していて、定義されていないか、スコープ外から private / protected にアクセスしたとき。public で定義済みのプロパティでは呼ばれません。宣言はすべて public、__callStatic だけは static も必要です。

実装するときは、__get と __isset をセットで用意すること、__call で処理できない呼び出しは BadMethodCallException で落とすこと、この2点を守るだけで事故がぐっと減ります。$obj->arr['key'] = 1; のような間接的な変更が効かないことと、静的プロパティには使えないことも頭の片隅に置いておいてください。そのうえで、名前が事前に決められる場面では普通のプロパティやメソッドを選ぶ——という切り分けができれば、マジックメソッドは扱いやすい道具になります。

参考ページ