Магические методы: полный разбор
В уроке про 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); // файл удалён детерминированно
Перегрузка методов: __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 логгера, который ты разбирал в уроке про логирование.
Объект как функция: __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; // соединение восстановится лениво
}
}
Отладка: __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 - как код из разных пакетов уживается в одном процессе.