Магічні методи - це точки, де движок передає керування вашому коду замість того, щоб кинути помилку або застосувати типову поведінку. Їх близько півтора десятка: __construct, __destruct, __get, __set, __isset, __unset, __call, __callStatic, __invoke, __toString, __clone, __serialize/__unserialize (з PHP 7.4 замість __sleep/__wakeup), __debugInfo, __set_state. Ключове слово в описі __get - «недоступна»: він викликається тільки тоді, коли властивості на обʼєкті немає або вона закрита від поточного контексту. Публічну оголошену властивість він не перехопить ніколи, а от після unset() оголошена властивість зникає з обʼєкта, і наступне читання вже піде через магію. З PHP 8.0 сигнатури перевіряються при компіляції: private function __get() або статичний __toString() дають фатальну помилку одразу, а не дивну поведінку в рантаймі.
Проблема __get/__set не в швидкості, хоча зайвий виклик методу там теж є. Вони видаляють інформацію з коду. Повернений тип - mixed, назви властивостей існують тільки у вигляді рядків усередині методу, і жоден інструмент їх не бачить: IDE не підказує, «Find usages» не знаходить, rename не перейменовує, PHPStan не має що перевіряти. Приклад у коді показує наслідок у чистому вигляді: $config->timeuot віддає null, і помилку ви знайдете на проді. Лікують це @property-анотаціями в PHPDoc класу, і саме тому в екосистемі Laravel живе laravel-ide-helper, який генерує сотні @property для моделей Eloquent. Анотації працюють, але це ручна копія схеми, яка застаріває тихо.
Окремий сюрприз чекає на масивах. $config->limits['db'] = 20 вимагає посилання на реальні дані, а __get повертає значення, тому PHP змінює тимчасову копію і повідомляє Indirect modification of overloaded property ... has no effect. Це повідомлення легко не помітити в продакшені з вимкненим виводом помилок. Теоретично рятує public function &__get(), який повертає посилання, але тоді ви віддаєте назовні внутрішній стан і втрачаєте контроль над записом. Простіше дати класу явний метод setLimit(string $key, int $value).
__call і __callStatic живуть за тими самими правилами, але мають чіткішу нішу: проксі, декоратор, фасад, query builder зі згенерованими методами. Умова придатності одна: невідомий виклик має закінчуватися BadMethodCallException, інакше опечатка в назві методу стає тихим null. Пара __invoke і __toString стоїть окремо, бо їхній контракт описаний у самій мові. Обʼєкт з __invoke проходить is_callable() і лягає в array_map() як звичайна функція, а клас з __toString з PHP 8.0 автоматично реалізує інтерфейс Stringable, тож його можна вимагати в типі параметра. Викидати винятки з __toString дозволено з PHP 7.4, до того це давало фатальну помилку. Ці два методи роблять код коротшим, нічого не приховуючи від інструментів.
Явний метод виграє щоразу, коли ви знаєте набір полів на етапі написання коду. Оголошена типізована властивість дає перевірку типу, автодоповнення й помилку компіляції при друкарці; геттер дає місце для документації й контрактів. З PHP 8.4 зявилися property hooks і lazy objects, які покривають більшість старих причин тягнутися до __get: обчислювана властивість, валідація при записі, ліниве завантаження. Магії лишається територія, де набір членів справді динамічний: рядок з бази, конфіг з довільного YAML, обгортка над чужим API. __constructі__destructу цьому списку стоять осібно, бо їх пишуть усі, проте деструктор варто сприймати як прибиральника після аварії: момент і порядок його виклику не гарантовані, тому все, що має статися в конкретній точці програми, пишеться явно черезfinally`.
declare(strict_types=1);
final class Config
{
/** @var array<string, mixed> */
private array $values = [];
public function __get(string $name): mixed
{
return $this->values[$name] ?? null; // друкарка в назві поверне null
}
public function __set(string $name, mixed $value): void
{
$this->values[$name] = $value; // ні типу, ні валідації
}
public function __isset(string $name): bool
{
return isset($this->values[$name]); // без цього isset() завжди false
}
}
$config = new Config();
$config->timeout = 30;
echo $config->timeuot; // null: ні винятку, ні підказки від IDE
var_dump(isset($config->timeout)); // true, і лише завдяки __isset
$config->limits = ['db' => 10];
$config->limits['db'] = 20; // Indirect modification of overloaded
// property: запис просто зникає
// __invoke: контракт, який розуміє вся мова
final class PriceFormatter
{
public function __construct(private string $currency = 'грн') {}
public function __invoke(int $cents): string
{
return number_format($cents / 100, 2, ',', ' ') . " {$this->currency}";
}
}
$format = new PriceFormatter();
echo $format(129_900); // 1 299,00 грн
print_r(array_map($format, [100, 250])); // працює як звичайний callable
Розділіть магію на дві купки вголос: `__invoke`, `__toString`, `__clone`, `__serialize` реалізують контракт, який движок уже описав, і код з ними лишається читабельним; `__get`, `__set`, `__call` вигадують API, якого в класі немає, тому їх виправдовує тільки роль проксі, декоратора чи ORM. Додайте фразу «магічну властивість не перейменує жоден рефакторинг» - це показує, що ви думали про підтримку, а не тільки про синтаксис.