Магические методы: полный разбор

В уроке про traits ты уже видел __get, __set и __call - способ перехватывать обращения к несуществующим свойствам и методам. Но это лишь треть картины. В PHP шестнадцать магических методов, и на них построены вещи, которыми ты будешь пользоваться каждый день: Eloquent-модели в Laravel, ленивые прокси в Doctrine, контейнеры зависимостей, fluent-билдеры.

Цель урока - не «выучить список», а понять, когда PHP вызывает каждый метод, что магия стоит в производительности и читаемости, и где проходит граница между «библиотечной магией» и антипаттерном в бизнес-коде.

Карта магических методов

Все магические методы начинаются с __ и вызываются интерпретатором автоматически в ответ на события:

Событие Метод
Создание / уничтожение объекта __construct, __destruct
Чтение / запись недоступного свойства __get, __set
isset() / unset() на недоступном свойстве __isset, __unset
Вызов недоступного метода __call, __callStatic
Объект как строка __toString
Объект как функция __invoke
Клонирование __clone
Сериализация __serialize, __unserialize (и legacy __sleep, __wakeup)
var_dump() __debugInfo
var_export() __set_state

«Недоступное» свойство - это либо несуществующее, либо private/protected при обращении снаружи. Если свойство объявлено и доступно, магия не вызывается - это главный источник недопонимания.

Жизненный цикл: __construct и __destruct

__construct ты используешь с первого урока про классы. Менее известен его антипод:

final class TempFile
{
    public function __construct(
        private readonly string $path,
    ) {
        touch($this->path);
    }

    public function __destruct()
    {
        // Вызывается, когда на объект не остаётся ссылок
        // или скрипт завершает работу
        if (file_exists($this->path)) {
            unlink($this->path);
        }
    }
}

$tmp = new TempFile('/tmp/report.csv');
// ... работа с файлом ...
unset($tmp); // файл удалён детерминированно
Момент вызова зависит от сборщика мусора: при циклических ссылках объект может жить до конца запроса. Не полагайся на `__destruct` для критичных вещей - закрытия транзакций, освобождения блокировок. Для этого есть явный `close()`/`release()` + `try/finally`. В долгоживущих процессах (воркеры, очереди) «отложенный» `__destruct` превращается в утечку ресурсов.

Перегрузка методов: __call и __callStatic

__call ты видел в traits-уроке. __callStatic - то же самое для статических вызовов, и именно на нём построены фасады Laravel и User::whereEmail(...) в Eloquent:

/**
 * @method static self whereEmail(string $email)
 * @method static self whereName(string $name)
 */
final class QueryBuilder
{
    /** @var array<string, string> */
    private array $conditions = [];

    public static function __callStatic(string $method, array $args): self
    {
        if (str_starts_with($method, 'where')) {
            $field = strtolower(substr($method, 5));
            $builder = new self();
            $builder->conditions[$field] = $args[0];
            return $builder;
        }

        throw new BadMethodCallException(sprintf(
            'Метод %s::%s не существует', self::class, $method,
        ));
    }
}

$q = QueryBuilder::whereEmail('dev@example.com'); // __callStatic

Обрати внимание на две вещи. Первая: при неизвестном методе мы бросаем BadMethodCallException, а не молчим - иначе опечатка wherEmail превратится в тихий баг. Вторая: PHPDoc @method сверху возвращает IDE и статическому анализатору знание о «виртуальных» методах - без него psalm/phpstan будут ругаться, а автодополнение ослепнет.

Объект как строка: __toString и Stringable

final class Money
{
    public function __construct(
        private readonly int $amountInCents,
        private readonly string $currency,
    ) {}

    public function __toString(): string
    {
        return sprintf('%.2f %s', $this->amountInCents / 100, $this->currency);
    }
}

$price = new Money(149900, 'RUB');
echo "Цена: {$price}"; // Цена: 1499.00 RUB

С PHP 8.0 класс с __toString автоматически реализует интерфейс Stringable - его можно указывать в типах: function log(string|Stringable $message). Именно так объявлены сигнатуры PSR-3 логгера, который ты разбирал в уроке про логирование.

Внутри `__toString` нельзя надёжно бросать исключения в старом коде (до PHP 7.4 это приводило к fatal error), и он вызывается неявно - в конкатенации, `echo`, сравнениях. Держи его тривиальным: форматирование и ничего больше. Запрос в базу внутри `__toString` - реальный баг из продакшена: `var_dump` объекта в логе внезапно делал 200 SQL-запросов.

Объект как функция: __invoke

__invoke превращает объект в callable. Это основа «single action»-классов: инварианты и зависимости - в конструкторе, работа - одним вызовом:

final class CalculateOrderTotal
{
    public function __construct(
        private readonly TaxPolicy $taxes,
    ) {}

    public function __invoke(Order $order): Money
    {
        $subtotal = $order->linesTotal();
        return $this->taxes->apply($subtotal);
    }
}

// Использование: объект передаётся туда, где ждут callable
$totals = array_map($calculateTotal, $orders);

Symfony использует __invoke для single-action контроллеров, Laravel - для invokable-контроллеров и джобов. Плюс перед обычным методом: такой класс - «функция с зависимостями», его удобно подменять в тестах и регистрировать в DI-контейнере.

Клонирование: __clone и ловушка shallow copy

clone в PHP делает поверхностную копию: свойства-объекты копируются по ссылке. __clone вызывается на копии сразу после клонирования - это шанс докопировать вложенные объекты:

final class Invoice
{
    public function __construct(
        public InvoiceNumber $number,
        public DateTimeImmutable $issuedAt,
        /** @var InvoiceLine[] */
        public array $lines = [],
    ) {}

    public function __clone(): void
    {
        // Без этого копия и оригинал разделяют одни и те же InvoiceLine
        $this->lines = array_map(
            static fn (InvoiceLine $line): InvoiceLine => clone $line,
            $this->lines,
        );
        $this->number = InvoiceNumber::next(); // у копии свой номер
    }
}

DateTimeImmutable докопировать не нужно - он и так неизменяемый. Правило: в __clone глубоко копируй только мутабельные вложенные объекты. Если всё состояние объекта immutable (readonly-свойства с immutable-типами) - __clone не нужен вовсе, и это ещё один аргумент в пользу иммутабельности из урока про чистые классы.

Сериализация: __serialize / __unserialize

Пара __serialize/__unserialize (PHP 7.4+) контролирует, что попадает в строку serialize() - и вытесняет legacy-пару __sleep/__wakeup:

final class DbConnection
{
    private ?PDO $pdo = null;

    public function __construct(
        private readonly string $dsn,
    ) {}

    public function __serialize(): array
    {
        // PDO нельзя сериализовать - сохраняем только DSN
        return ['dsn' => $this->dsn];
    }

    public function __unserialize(array $data): void
    {
        $this->dsn = $data['dsn'];
        $this->pdo = null; // соединение восстановится лениво
    }
}
Никогда не вызывай `unserialize()` на данных, пришедших от пользователя (cookie, скрытое поле формы, параметр URL). Цепочки «POP gadget» из `__wakeup`/`__destruct` библиотечных классов позволяют выполнить произвольный код - это классика отчётов bug bounty. Для обмена данными используй JSON из [урока про JSON](/course/php/json), а `unserialize` - только для доверенных источников (кэш, очередь) и с опцией `['allowed_classes' => [...]]`.

Отладка: __debugInfo

var_dump большого объекта с соединениями и буферами нечитаем. __debugInfo возвращает то, что увидит разработчик:

public function __debugInfo(): array
{
    return [
        'dsn' => $this->dsn,
        'connected' => $this->pdo !== null,
        // пароль и сам PDO наружу не попадают
    ];
}

Побочный бонус - секреты не утекают в логи и error-трекеры, куда часто попадает дамп объекта целиком.

Цена магии

Магический вызов дороже обычного: PHP сначала ищет реальный метод/свойство, не находит, затем диспатчит __call/__get с упаковкой аргументов в массив. На горячем пути (тысячи вызовов на запрос) разница измерима - поэтому Eloquent-модели с их сплошным __get заметно медленнее «голых» DTO, а Doctrine генерирует прокси-классы кодом, а не через __call.

Но главная цена - не такты, а прозрачность:

// Что здесь происходит? Без чтения __get не узнать:
$user->fullName;   // свойство? вычисляемое? опечатка, вернувшая null?

// Явный метод отвечает сам:
$user->fullName(); // метод, вот его сигнатура и тип возврата

Рабочее правило то же, что и в traits-уроке, только теперь для всего арсенала: магия - для библиотечного и инфраструктурного кода (ORM, билдеры, контейнеры, прокси), бизнес-логика - только явные методы и свойства. И если магию пишешь - сопровождай её @method/@property PHPDoc-аннотациями, чтобы IDE и статанализ не слепли.

В Symfony-треке этого курса магия встретится ещё раз: ленивые сервисы контейнера и прокси Doctrine устроены ровно на этих механизмах - см. обзорный урок про Symfony.

Мини-задание

  • Класс Money с __toString; проверь, что log(sprintf(...)) и интерполяция в строку дают одинаковый результат, а тип-хинт Stringable принимает объект
  • Fluent-билдер Query::whereName('...')->whereAge(30) через __callStatic + __call; на неизвестный префикс бросай BadMethodCallException; добавь @method-аннотации и убедись, что IDE подсказывает методы
  • Класс Basket со свойством-массивом объектов Item: покажи баг shallow copy (измени Item в клоне - изменится оригинал), почини через __clone
  • __serialize/__unserialize для класса с несериализуемым ресурсом (например, PDO); проверь цикл unserialize(serialize($obj))
  • Вызови var_dump до и после добавления __debugInfo, скрывающего «секретное» поле

Следующий урок: namespaces и Composer - как код из разных пакетов уживается в одном процессе.