[PHP]__set()とは?アクセスできないプロパティへの書き込みを動的に処理する方法を徹底解説

PHP

はじめに

前回の記事では、アクセスできないプロパティの「読み取り」を動的に処理する __get() を解説しました。今回取り上げる __set() は、その対となる、プロパティへの「書き込み」を動的に処理するマジックメソッドです。

$obj->undefinedProperty = 'value' のように、オブジェクトに存在しない(あるいは private/protected でアクセスできない)プロパティに値を代入しようとすると、通常はそのまま新しい public プロパティとして動的に作成されてしまいます。しかし、クラスに __set() が定義されていると、この書き込み操作が自動的に __set() に転送され、値の検証、変換、あるいは書き込みの拒否といった制御を行えるようになります。これは、不変オブジェクトの実現や、入力値のバリデーションを一元化する上で非常に重要な仕組みです。本記事では基本的な使い方から、__get() との連携、注意点まで詳しく解説します。


メソッド概要

項目内容
メソッド名__set()
種別マジックメソッド
シグネチャpublic function __set(string $name, mixed $value): void
引数1$name — 書き込もうとしたプロパティ名
引数2$value — 書き込もうとした値
呼び出しタイミングアクセス不可能(未定義、またはprivate/protected)なプロパティに値を代入しようとした時
戻り値なし(void)
対応バージョンPHP 5.0以降
対になるメソッド__get()(読み取り時)、__isset()、__unset()

__set()がないとどうなるか(イメージ図)

  class User {
      private string $name;
  }

  $user = new User();
  $user->nam = '太郎';  ← "name"のタイプミス!
              │
              ▼
  ┌───────────────────────────┐
  │ __set()が定義されていない場合:        │
  │ "nam" という新しいpublicプロパティが   │
  │ 静かに作成されてしまう(意図しないバグ) │
  └───────────────────────────┘

  ┌───────────────────────────┐
  │ __set()が定義されている場合:          │
  │ __set('nam', '太郎') が呼ばれ、        │
  │ 開発者が明示的にエラーとして検知できる  │
  │ ★この記事の対象                      │
  └───────────────────────────┘

ポイントは、__set() を定義することで、タイプミスによる意図しないプロパティの動的生成を防ぎ、書き込み内容を検証する機会を得られるという点です。これは、private プロパティへのカプセル化を徹底する上で欠かせない仕組みです。


実践サンプル7選

例1:基本的な使い方

<?php

class BasicSetDemo
{
    private array $data = [];

    public function __set(string $name, mixed $value): void
    {
        // 書き込もうとしたプロパティ名と値を内部配列に格納する
        echo "__set('{$name}', " . var_export($value, true) . ") が呼ばれました" . PHP_EOL;
        $this->data[$name] = $value;
    }

    public function __get(string $name): mixed
    {
        return $this->data[$name] ?? null;
    }
}

$demo = new BasicSetDemo();
$demo->title = 'サンプルタイトル';
echo $demo->title . PHP_EOL;

例2:値の検証を一元化するクラス

<?php

class ValidatedForm
{
    private array $fields = [];

    /**
     * __set()内でフィールドごとのバリデーションルールを適用し、
     * 不正な値の書き込みを未然に防ぐ
     */
    public function __set(string $name, mixed $value): void
    {
        if ($name === 'email' && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException('不正なメールアドレスです');
        }

        if ($name === 'age' && (!is_numeric($value) || $value < 0)) {
            throw new InvalidArgumentException('年齢は0以上の数値である必要があります');
        }

        $this->fields[$name] = $value;
    }

    public function __get(string $name): mixed
    {
        return $this->fields[$name] ?? null;
    }
}

$form = new ValidatedForm();
$form->email = 'taro@example.com';
echo $form->email . PHP_EOL;

try {
    $form->age = -5;
} catch (InvalidArgumentException $e) {
    echo 'エラー: ' . $e->getMessage() . PHP_EOL;
}

例3:外部からの書き込みを完全に禁止する不変オブジェクト

<?php

class ImmutableConfig
{
    private array $settings;

    public function __construct(array $settings)
    {
        $this->settings = $settings;
    }

    public function __get(string $name): mixed
    {
        return $this->settings[$name] ?? null;
    }

    /**
     * __set()を定義し、常に例外をスローすることで、
     * コンストラクタ以外での変更を一切禁止する
     */
    public function __set(string $name, mixed $value): void
    {
        throw new LogicException('このオブジェクトは変更できません(イミュータブル)');
    }
}

$config = new ImmutableConfig(['debug' => true]);
echo $config->debug ? 'true' : 'false';
echo PHP_EOL;

try {
    $config->debug = false;
} catch (LogicException $e) {
    echo 'エラー: ' . $e->getMessage() . PHP_EOL;
}

例4:プロパティ名の表記ゆれを吸収して正規化するクラス

<?php

class NormalizingModel
{
    private array $attributes = [];

    /**
     * どのような大文字小文字の組み合わせで書き込まれても、
     * 内部的にはスネークケースに統一して保存する
     */
    public function __set(string $name, mixed $value): void
    {
        $snakeCase = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $name));
        $this->attributes[$snakeCase] = $value;
    }

    public function __get(string $name): mixed
    {
        $snakeCase = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $name));
        return $this->attributes[$snakeCase] ?? null;
    }
}

$model = new NormalizingModel();
$model->firstName = '太郎'; // キャメルケースで書き込む
echo $model->first_name . PHP_EOL; // スネークケースでも取得できる

例5:型変換を自動的に行うクラス

<?php

class TypeCoercingEntity
{
    private array $data = [];

    private array $castRules = [
        'price'    => 'float',
        'quantity' => 'int',
        'active'   => 'bool',
    ];

    /**
     * 書き込まれた値を、定義済みのルールに従って
     * 自動的に適切な型へ変換してから保存する
     */
    public function __set(string $name, mixed $value): void
    {
        if (isset($this->castRules[$name])) {
            settype($value, $this->castRules[$name]);
        }

        $this->data[$name] = $value;
    }

    public function __get(string $name): mixed
    {
        return $this->data[$name] ?? null;
    }
}

$entity = new TypeCoercingEntity();
$entity->price = '1500.50';   // 文字列で渡しても...
$entity->quantity = '3';
var_dump($entity->price);     // float(1500.5) として保存される
var_dump($entity->quantity);  // int(3) として保存される

例6:タイプミスを検知する厳格なプロパティアクセス制御

<?php

class StrictEntity
{
    private string $name = '';
    private int $age = 0;

    /**
     * 定義済みのプロパティ名以外への書き込みを
     * タイプミスとみなして例外をスローする
     */
    public function __set(string $name, mixed $value): void
    {
        if (!property_exists($this, $name)) {
            throw new OutOfBoundsException(
                sprintf("プロパティ '%s' は %s に存在しません(タイプミスの可能性があります)", $name, static::class)
            );
        }

        $this->$name = $value;
    }
}

$entity = new StrictEntity();

try {
    $entity->nmae = '太郎'; // "name"のタイプミス
} catch (OutOfBoundsException $e) {
    echo 'エラー: ' . $e->getMessage() . PHP_EOL;
}

例7:変更履歴を自動的に記録するクラス

<?php

class TrackedEntity
{
    private array $data = [];
    private array $changeLog = [];

    /**
     * プロパティへの書き込みのたびに、
     * 変更前後の値を履歴として自動的に記録する
     */
    public function __set(string $name, mixed $value): void
    {
        $oldValue = $this->data[$name] ?? null;

        $this->changeLog[] = [
            'property' => $name,
            'old'      => $oldValue,
            'new'      => $value,
            'time'     => date('Y-m-d H:i:s'),
        ];

        $this->data[$name] = $value;
    }

    public function getChangeLog(): array
    {
        return $this->changeLog;
    }
}

$entity = new TrackedEntity();
$entity->status = 'draft';
$entity->status = 'published';
print_r($entity->getChangeLog());

関連機能との比較

機能役割__set()との違い
__set()アクセス不可能なプロパティへの書き込みを処理本記事の対象。プロパティの「書き込み」時に発動する
__get()アクセス不可能なプロパティの読み取りを処理「読み取り」時に発動する、対になるマジックメソッド(前回記事を参照)
__isset()isset()/empty()の判定を処理プロパティの「存在確認」時に発動する
__unset()unset()による削除を処理プロパティの「削除」時に発動する
readonlyプロパティ(PHP 8.1以降)初期化後の変更を言語レベルで禁止する__set()による手動の拒否ロジックとは異なり、コンパイル時に保証される不変性

よくある落とし穴(注意点)

  1. private/protectedプロパティが存在しない場合にのみ発動する __set() は、「アクセス可能な同名のpublicプロパティが存在しない」場合、または「privateプロパティにクラス外部からアクセスしようとした」場合に発動します。クラス内部から $this->name = 'x' のように書き込む場合、対応する private プロパティが定義されていれば __set() は呼ばれません。
  2. 検証ロジックを__set()に書きすぎると可読性が落ちる 多数のプロパティそれぞれに異なる検証ルールがある場合、__set() 内の条件分岐が複雑になりがちです。プロパティごとに専用のsetterメソッドを用意する設計の方が見通しが良いこともあります(例2のような簡単なケースを超える場合は注意が必要です)。
  3. __get()を実装し忘れると、書き込んだ値を読み取れない __set() だけを定義して __get() を実装していないと、$obj->property = 'value' で書き込んだ後、$obj->property で読み取ろうとした際に null が返ったり警告が出たりします。読み書き両方が必要な場合は、必ずセットで実装しましょう。
  4. 意図しない動的プロパティ作成への対策として重要 PHP 8.2以降、未定義プロパティへの動的な書き込みは非推奨とされる方向に進んでいますが、__set() を適切に実装しておくことで、こうした将来的な言語仕様の変化にも対応しやすくなります。
  5. デバッグ時にスタックトレースが追いにくくなる __set() の中で複雑な変換や副作用のある処理を行うと、「なぜこの値がこの形になっているのか」を追跡するのが難しくなることがあります。処理内容はなるべくシンプルに保つことが望ましいです。

まとめ

観点まとめ
何をするメソッドか存在しない、またはアクセスできないプロパティへの書き込みが行われた際に動的に処理する
主な用途値のバリデーション、不変オブジェクトの実現、型変換の自動化、変更履歴の記録
連携するメソッド__get()(読み取り)、__isset()(存在確認)、__unset()(削除)
発動条件アクセス不可能なプロパティへの書き込み時(privateプロパティが存在する場合は発動しない)
注意点__get()との対での実装、検証ロジックの複雑化への注意、タイプミス検知としての活用

__set() は、プロパティへの書き込みを一元的に制御できる強力な仕組みであり、特に「タイプミスによる意図しないプロパティ作成を防ぐ」「不変オブジェクトを実現する」といった場面でその真価を発揮します。__get() と対で実装し、検証や変換のロジックが複雑になりすぎないよう注意しながら活用しましょう。

タイトルとURLをコピーしました