Александр Некрасов · Автономное руководство

PHP: полный мануал с примерами, функциями и ООП

Разделы этой страницы
  1. Магические методы: полезные протоколы и скрытые ловушки
  2. Позволить PHP получить подпись заказа
  3. Оператор определяет, какой специальный метод спросить
  4. Настроить фильтр один раз и передать его в array_filter
  5. Виртуальное поле требует схемы и отказа при опечатке
  6. Подпись и безопасная HTML-страница — разные преобразования
  7. Совместимость __call должна иметь срок и список имён
  8. Явное представление для обмена проще скрытого графа
  9. Дальше: полный договор языка и специальные случаи
  10. Сохранить закладку импорта и восстановить проверенное состояние
  11. __call и __callStatic: ограниченный переход со старого API
  12. Удаление виртуального поля, диагностический вид и экспорт
  13. Что empty и isset спрашивают у объекта с виртуальными свойствами
  14. Старый __sleep и новый __serialize: что сохраняется между запусками
  15. При восстановлении нужен класс: строка не содержит методов
  16. Читать старые Serializable-снимки и записывать новый формат
  17. Функции и методы этой темы
  18. Код и руководство с собой
PHP 8.5.11149 глав4458 карточек справочника
Дерево руководства

Глава 50 · От основ к архитектуре

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

Разбираем __get/__set, __call, __invoke, __toString, сериализацию и отладочное представление. Не даём удобному синтаксису спрятать I/O или утечку данных.

Позволить PHP получить подпись заказа

В письме нужен номер ORD-42. У объекта можно вызвать label явно; если договор требует строкового представления, PHP умеет вызвать заранее известное имя __toString. Магический метод — обычный метод со специальным моментом вызова.

Что получится. echo $order напечатает ORD-42. Сравним его с явным label.

Как проходит конкретный вызов
  1. echo получает объект OrderNumber
  2. PHP ищет допустимое строковое представление
  3. Вызывает __toString у того же объекта
  4. Метод возвращает строку, echo печатает её
Явный метод и вызов PHP дают одну подписьСамостоятельный пример
<?php
declare(strict_types=1);

class OrderNumber
{
    public function __construct(private int $id) {}
    public function label(): string { return 'ORD-' . $this->id; }
    public function __toString(): string { return $this->label(); }
}
$order = new OrderNumber(42);
echo $order->label(), "\n";
echo $order, "\n";
Ожидаемый результат
ORD-42
ORD-42

__toString пишется с двумя подчёркиваниями и должен соблюдать строковый договор. PHP вызывает его при соответствующем приведении объекта к строке. Он не выполняется при любой передаче объекта и не заменяет все операции заказа.

Оператор определяет, какой специальный метод спросить

Наглядная модель

Объект хранит порог, __invoke проверяет товары

Для фильтра каталога нужен один порог цены и одинаковая проверка каждой записи. Вызываемый объект соединяет настройку и действие.

  1. AbovePrice(10000)

    Конструктор сохраняет порог в объекте. Создание фильтра ещё не перебирает товары.

  2. array_filter получает объект

    Во входе TEA за 5000 и COFFEE за 15000. Один фильтр используется для обеих записей.

  3. PHP вызывает __invoke для записи

    Метод читает сохранённый порог: TEA → false, COFFEE → true.

  4. Результат содержит COFFEE

    array_filter оставляет прошедшую проверку запись; исходный порог не дублируется в каждом вызове.

Магический метод вызывается по определённому правилу языка. Здесь это обычное вычисление без I/O. Наличие __invoke само по себе не делает объект асинхронным и не запускает его в фоне.

Действие вызывающегоМетод PHPПример пользы
Превратить объект в строку__toStringНомер заказа
Вызвать объект как функцию__invokeНастроенный фильтр каталога
Прочитать недоступное свойство__getОграниченный адаптер старой схемы
Вызвать недоступный метод__call / __callStaticВременная совместимость API
Сохранить / восстановить состояние__serialize / __unserializeПроверенная закладка импорта

Эти имена зарезервированы PHP. Их сигнатуры и видимость должны соответствовать протоколу: большинство magic-методов публичны; __construct, __destruct и __clone имеют специальные правила. Не придумывайте собственные имена с __ ради автоматического вызова — PHP не обязан их знать.

Настроить фильтр один раз и передать его в array_filter

Выбрать товары с ценой от 300 рублейСамостоятельный пример
<?php
declare(strict_types=1);

class AtLeastPrice
{
    public function __construct(private int $minimumKopecks) {}
    public function __invoke(array $product): bool
    {
        return $product['price_kopecks'] >= $this->minimumKopecks;
    }
}
$products = [
    ['title' => 'Чай', 'price_kopecks' => 25900],
    ['title' => 'Кофе', 'price_kopecks' => 45000],
];
$filter = new AtLeastPrice(30000);
foreach (array_filter($products, $filter) as $product) {
    echo $product['title'], ': ', $product['price_kopecks'], " коп.\n";
}
Ожидаемый результат
Кофе: 45000 коп.

array_filter передаёт каждую запись в callable $filter. Поскольку это объект с __invoke, PHP вызывает этот метод; bool определяет оставить ли товар. В этом примере структура записей уже известна. Для единственного фильтра короткое замыкание может быть проще объекта.

От второй записи к результату
  1. array_filter берёт Кофе / 45000
  2. $filter->__invoke получает запись в $product
  3. Читает minimumKopecks = 30000
  4. 45000 >= 30000 даёт true
  5. Запись остаётся, внешний foreach печатает её

Виртуальное поле требует схемы и отказа при опечатке

Совместимое поле locale без произвольного доступаСамостоятельный пример
<?php
declare(strict_types=1);

class Settings
{
    private array $values = ['locale' => 'ru'];
    public function __get(string $name): mixed
    {
        if (!array_key_exists($name, $this->values)) {
            throw new OutOfBoundsException('Неизвестная настройка: ' . $name);
        }
        return $this->values[$name];
    }
}
$settings = new Settings();
echo 'Язык: ', $settings->locale, "\n";
try { echo $settings->loacle; }
catch (OutOfBoundsException $error) { echo $error->getMessage(), "\n"; }
Ожидаемый результат
Язык: ru
Неизвестная настройка: loacle

Обращение к недоступному объявленному или отсутствующему полю может вызвать __get. Мы разрешили только ключи внутренней схемы. property_exists не вызывает __get ради поиска виртуальных полей. Для нового простого API явный locale() или объявленное свойство обычно легче исследовать инструментами.

__set, __isset и __unset задают отдельные операции: существование __get не реализует их автоматически. Динамические свойства с PHP 8.2 обычно deprecated; magic-адаптер не повод скрывать любую опечатку. Ссылки и изменение вложенного массива через виртуальное поле требуют отдельного договора, показанного в справочнике.

Подпись и безопасная HTML-страница — разные преобразования

Экранировать пользовательское название при выводе в HTMLСамостоятельный пример
<?php
declare(strict_types=1);

class ProductLabel
{
    public function __construct(private string $title) {}
    public function __toString(): string { return $this->title; }
}
$label = new ProductLabel('<Чай & кофе>');
echo htmlspecialchars((string) $label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), "\n";
Ожидаемый результат
&lt;Чай &amp; кофе&gt;

Это точный текст HTML-экранированного результата, показанный как CLI-вывод. __toString только возвращает название; контекст HTML требует htmlspecialchars. Для JSON используется json_encode, для SQL — параметры запроса, а не это экранирование.

__debugInfo может изменить вывод var_dump, но не скрывает секреты от всех способов сериализации и логирования. Сохраняйте минимум необходимых данных и не рассчитывайте на один магический метод как на общую защиту.

Совместимость __call должна иметь срок и список имён

Если listDeals переименован в deals, __call может временно поддержать одно известное старое имя. Он получает имя недоступного метода и массив аргументов. Нельзя отправлять любое имя в HTTP API: опечатка превратится в реальную команду. Используйте явную карту, проверяйте аргументы и отказывайте неизвестным именам через BadMethodCallException.

method_exists проверяет реальные объявления и не перечисляет все возможные обращения через __call. Документируйте совместимый слой и удаляйте его после миграции. Полный самостоятельный адаптер __call/__callStatic сохранён ниже.

Попробуйте. Добавьте верхний предел в фильтр AtLeastPrice или переименуйте его в PriceRange.

Разобрать решение

Примите minimumKopecks и maximumKopecks в конструкторе, проверьте порядок границ; __invoke возвращает true только внутри диапазона. Если изменилось назначение объекта, переименуйте класс, а не оставляйте вводящее в заблуждение имя.

Явное представление для обмена проще скрытого графа

Сформировать публичную карточку покупателяСамостоятельный пример
<?php
declare(strict_types=1);

class Customer implements JsonSerializable
{
    public function __construct(private int $id, private string $name, private string $internalNote) {}
    public function jsonSerialize(): array { return ['id' => $this->id, 'name' => $this->name]; }
}
$customer = new Customer(7, 'Анна', 'Позвонить после 18:00');
echo json_encode($customer, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), "\n";
Ожидаемый результат
{"id":7,"name":"Анна"}

JsonSerializable — интерфейс, а jsonSerialize не относится к методам с двойным подчёркиванием. Здесь он задаёт явный внешний набор полей. Не путайте JSON для обмена с serialize для PHP-состояния.

__serialize возвращает массив состояния, __unserialize восстанавливает его по проверенному договору; при наличии они имеют приоритет над __sleep/__wakeup. Старый Serializable имеет ограничения и устаревание, разобранные ниже. Не передавайте недоверенные данные в unserialize: лучше разобрать JSON и явно создать проверенные объекты.

Дальше: полный договор языка и специальные случаи

Ниже показаны закладка импорта, совместимые вызовы, isset/empty, удаление виртуальных полей, __set_state и миграция сериализации. Эти специальные правила проходят после понятного момента вызова PHP.

Разделы ниже сохраняют специальные правила официального PHP Manual: Сохранить закладку импорта и восстановить проверенное состояние, __call и __callStatic: ограниченный переход со старого API, Удаление виртуального поля, диагностический вид и экспорт, Что empty и isset спрашивают у объекта с виртуальными свойствами, Старый __sleep и новый __serialize: что сохраняется между запусками, При восстановлении нужен класс: строка не содержит методов, Читать старые Serializable-снимки и записывать новый формат.

Сохранить закладку импорта и восстановить проверенное состояние

Долгая загрузка остановилась после страницы 3. Для продолжения достаточно версии формата и номера следующей страницы; HTTP-клиент, открытое соединение и токен хранить в снимке не требуется. Здесь используем доверенную строку, которую только что создали сами. Сначала нужны инвариант конструктора и граница безопасной сериализации.

Serialize вызывает __serialize и сохраняет возвращённый массив вместе с информацией о классе. Unserialize восстанавливает объект и передаёт массив в __unserialize; обычный __construct автоматически не запускается. Значит проверку состояния нужно явно выполнить и на пути восстановления.

Два входа в одно проверенное состояние
  1. new Bookmark(3) → конструктор → проверка страницы
  2. __serialize → массив версии и страницы → строка
  3. Доверенная строка → __unserialize → та же проверка
  4. Готовый объект → продолжение импорта
Восстановление не получает права обходить проверкуСамостоятельный пример
<?php
declare(strict_types=1);

final class ImportBookmark
{
    private int $page;

    public function __construct(int $page)
    {
        $this->setPage($page);
    }

    private function setPage(int $page): void
    {
        if ($page < 1 || $page > 1000) {
            throw new InvalidArgumentException('Недопустимая страница');
        }
        $this->page = $page;
    }

    public function page(): int
    {
        return $this->page;
    }

    public function __serialize(): array
    {
        return ['version' => 1, 'page' => $this->page];
    }

    public function __unserialize(array $data): void
    {
        if (($data['version'] ?? null) !== 1 || !is_int($data['page'] ?? null)) {
            throw new UnexpectedValueException('Формат закладки не поддерживается');
        }
        $this->setPage($data['page']);
    }
}

$original = new ImportBookmark(3);
$saved = serialize($original);
$restored = unserialize($saved, ['allowed_classes' => [ImportBookmark::class]]);
if (!$restored instanceof ImportBookmark) {
    throw new UnexpectedValueException('Ожидалась закладка');
}
echo 'Следующая страница: ', $restored->page(), "\n";
echo $original === $restored ? "Тот же экземпляр\n" : "Новый экземпляр\n";
try {
    $restored->__unserialize(['version' => 1, 'page' => 0]);
} catch (InvalidArgumentException $error) {
    echo "Проверка отклонила страницу\n";
}
echo 'После отказа: ', $restored->page(), "\n";
Ожидаемый результат
Следующая страница: 3
Новый экземпляр
Проверка отклонила страницу
После отказа: 3

Прямой вызов __unserialize в конце — изолированная проверка нашей функции валидации, а не рекомендуемый прикладной API. SetPage проверяет до присваивания, поэтому плохое значение не портит уже существующий объект. Для загрузки нескольких полей сначала проверьте весь набор, потом меняйте состояние.

Allowed_classes здесь дополнительно ограничивает ожидаемый тип, но не делает недоверенный serialized-ввод безопасным. Внешние HTTP-данные принимайте как JSON со схемой и проверкой. Сериализованная строка не шифрует поля и не подтверждает автора; подпись, срок жизни и границы доверия относятся к отдельному протоколу.

Восстановлению нужен загруженный класс или работающий автозагрузчик. Методы в строке не сохраняются: выполняется текущий код установленного класса. Поэтому изменение формата требует стратегии версии, миграции или отклонения старого снимка. Для переносимой очереди заданий часто удобнее JSON из простых ID и отдельная загрузка актуального состояния.

Старые __sleep возвращают имена сохраняемых свойств, а __wakeup восстанавливает окружение. С PHP 8.5 эта пара soft-deprecated: руководство рекомендует переход к __serialize/__unserialize, без обещания обязательного runtime-предупреждения. При наличии новой пары PHP выбирает её вместо старой. Интерфейс Serializable также не следует выбирать для нового формата.

__call и __callStatic: ограниченный переход со старого API

После переименования метода старые интеграции ещё вызывают oldList. На короткий период можно перевести один известный вызов в новый метод. PHP направляет обращение к недоступному методу в __call; имя и аргументы приходят отдельно. Это полезно для миграции, но явный обычный метод-обёртка часто проще для IDE и читателя.

Старое имя проходит ограниченный переходник
  1. oldClient создаёт локального клиента
  2. oldList попадает в __call
  3. Разрешённое имя вызывает listDeals
  4. Неизвестное действие отклоняется
Разрешён только один старый вызовСамостоятельный пример
<?php
declare(strict_types=1);

class LocalDeals
{
    public function listDeals(): array
    {
        return [['ID' => 42, 'TITLE' => 'Учебная сделка']];
    }

    public function __call(string $name, array $arguments): mixed
    {
        if ($name === 'oldList' && $arguments === []) {
            return $this->listDeals();
        }
        throw new BadMethodCallException('Неизвестный вызов');
    }

    public static function __callStatic(string $name, array $arguments): mixed
    {
        if ($name === 'oldClient' && $arguments === []) {
            return new self();
        }
        throw new BadMethodCallException('Неизвестная фабрика');
    }
}

$client = LocalDeals::oldClient();
echo $client->oldList()[0]['TITLE'], "\n";
try {
    $client->deleteEverything();
} catch (BadMethodCallException $error) {
    echo "Неизвестная операция отклонена\n";
}
echo method_exists($client, 'oldList') ? "Метод объявлен\n" : "Объявления oldList нет\n";
Ожидаемый результат
Учебная сделка
Неизвестная операция отклонена
Объявления oldList нет

Список здесь — локальный fixture, сеть не вызывается. __CallStatic должен быть static; у него нет $this. __Call получает аргументы массивом, поэтому правила количества и типов нужно проверить самостоятельно. Method_exists ищет реальное объявление и не угадывает, какие имена разрешает наша магия.

Не передавайте пришедшее из HTTP имя напрямую в $this->$name(...$arguments). Так внутренние операции легко становятся внешним API без намеренного разрешения. Если набор операций известен, явные методы и таблица разрешённых команд проверяемее универсального перенаправления.

PHP называет это overloading, но это не перегрузка нескольких одноимённых методов по набору типов, как в некоторых других языках. Объявить несколько методов одного класса с одним именем и разными сигнатурами нельзя. Магические методы должны соблюдать сигнатуры PHP; произвольные имена с префиксом __ оставлены языку.

Удаление виртуального поля, диагностический вид и экспорт

В объекте с виртуальными полями чтение и запись уже обрабатываются специально. Unset требует отдельного решения: удалять элемент внутреннего массива или запрещать действие. Наличие __get не задаёт автоматически семантику остальных операций.

Виртуальное поле ведёт во внутренний словарь
  1. values содержит theme = dark
  2. Чтение theme обслуживает __get
  3. unset(theme) вызывает __unset
  4. __isset подтверждает отсутствие элемента
Явно удаляем значение из внутреннего словаряСамостоятельный пример
<?php
declare(strict_types=1);

class LocalPreferences
{
    private array $values = ['theme' => 'dark'];

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

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

    public function __unset(string $name): void
    {
        unset($this->values[$name]);
    }
}

$preferences = new LocalPreferences();
echo $preferences->theme, "\n";
unset($preferences->theme);
echo isset($preferences->theme) ? "Настройка есть\n" : "Настройки нет\n";
Ожидаемый результат
dark
Настройки нет

Значение живёт в values, динамическое свойство theme не создаётся. Для фиксированной модели предпочтительнее объявленные поля или явный removeTheme. Эта компактная карта не предоставляет запись извне и не является готовой системой настроек с проверкой схемы.

Что показать в дампе и как восстановить известное значениеСамостоятельный пример
<?php
declare(strict_types=1);

final class ExportOptions
{
    public function __construct(private string $format)
    {
        if (!in_array($format, ['csv', 'json'], true)) {
            throw new InvalidArgumentException('Неизвестный формат');
        }
    }

    public function __debugInfo(): array
    {
        return ['format' => $this->format, 'description' => 'Настройки экспорта'];
    }

    public static function __set_state(array $properties): object
    {
        if (!is_string($properties['format'] ?? null)) {
            throw new InvalidArgumentException('Ожидалась строка формата');
        }
        return new self($properties['format']);
    }

    public function format(): string
    {
        return $this->format;
    }
}

$options = new ExportOptions('csv');
ob_start();
var_dump($options);
$diagnostic = ob_get_clean();
echo str_contains($diagnostic, 'Настройки экспорта') ? "Дамп использует __debugInfo\n" : "Нет описания\n";
$phpExpression = var_export($options, true);
echo str_contains($phpExpression, '__set_state') ? "Экспорт содержит вызов восстановления\n" : "Нет вызова\n";
$copy = ExportOptions::__set_state(['format' => 'csv']);
echo $copy->format(), "\n";
Ожидаемый результат
Дамп использует __debugInfo
Экспорт содержит вызов восстановления
csv

Буфер перехватывает var_dump, поэтому пример не зависит от номера объекта в дампе. Var_export возвращает текст PHP-выражения; при экспорте сам __set_state ещё не выполняется. Мы проверяем наличие вызова в тексте и отдельно вызываем восстановление с известным массивом, без eval и без запуска внешнего PHP-кода.

__DebugInfo управляет диагностическим представлением var_dump, но не является общей защитой секретов: иной логгер, JSON-экспорт или непосредственный доступ могут пройти другим путём. Не проверяйте отсутствие утечки только одним красивым дампом. В __debugInfo возвращайте массив; возврат null устарел в PHP 8.5.

__Set_state нужен для классов, которые намеренно поддерживают PHP-экспорт конфигурации. Var_export не гарантирует, что каждый встреченный объект умеет восстановиться: отсутствие метода обнаружится при выполнении созданного выражения. Если нет осознанной причины генерировать PHP-файл, простая проверенная JSON-конфигурация легче переносится.

Что empty и isset спрашивают у объекта с виртуальными свойствами

Проверка настройки иногда пишет «не задано», хотя строка "0" действительно получена от сервиса. Разберём отдельно наличие значения и его истинность. До этого опыта полезно пройти __get и __set. Здесь данные находятся в обычном массиве внутри объекта; внешнее имя свойства переводится в ключ массива.

Isset вызывает __isset для недоступного свойства. Empty сначала узнаёт через __isset, есть ли значение; при положительном ответе получает его через __get и проверяет на пустоту. Поэтому наличие __get само по себе ещё не задаёт согласованную проверку empty.

Проверка не равна чтению
  1. isset(note): __isset возвращает false для null
  2. empty(enabled): __isset отвечает true
  3. __get возвращает строку 0
  4. empty считает эту строку пустой
Проследим два вопроса к настройкеСамостоятельный пример
<?php
declare(strict_types=1);

class ReportSettings
{
    private array $data = ['enabled' => '0', 'name' => 'Отчёт', 'note' => null];
    public array $events = [];

    public function __isset(string $name): bool
    {
        $this->events[] = 'isset:' . $name;
        return isset($this->data[$name]);
    }

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

$settings = new ReportSettings();
echo isset($settings->note) ? "Есть\n" : "Нет ненулевого значения\n";
echo empty($settings->enabled) ? "Пусто по правилам empty\n" : "Не пусто\n";
echo empty($settings->name) ? "Без имени\n" : "Имя задано\n";
echo implode(', ', $settings->events), "\n";
Ожидаемый результат
Нет ненулевого значения
Пусто по правилам empty
Имя задано
isset:note, isset:enabled, get:enabled, isset:name, get:name

Строка 0 существует, но empty даёт true. Для формы «значение обязательно, ноль разрешён» нужна явная проверка допустимого типа и значения. В собственном __isset можно выбрать другую семантику, например array_key_exists для наличия ключа даже с null, но тогда это необходимо документировать.

Одно имя снаружи и внутри класса идёт разными путямиСамостоятельный пример
<?php
declare(strict_types=1);

class ReportDraft
{
    private string $status = 'Проверяется';
    private array $extra = [];
    public array $events = [];

    public function currentStatus(): string { return $this->status; }
    public function __get(string $name): mixed
    {
        $this->events[] = 'get:' . $name;
        return $name === 'status' ? 'Доступен только просмотр' : ($this->extra[$name] ?? null);
    }
    public function __set(string $name, mixed $value): void
    {
        $this->events[] = 'set:' . $name;
        $this->extra[$name] = $value;
    }
}

$draft = new ReportDraft();
echo $draft->status, "\n";
echo $draft->currentStatus(), "\n";
$assigned = $draft->limit = 10;
echo $assigned, "\n";
echo implode(', ', $draft->events), "\n";
Ожидаемый результат
Доступен только просмотр
Проверяется
10
get:status, set:limit

Снаружи private status недоступно, поэтому работает __get. В currentStatus поле доступно напрямую. В цепочке присваивания результатом является присвоенное 10: PHP не вызывает __get для его повторного чтения, а возвращаемое значение __set не служит результатом присваивания.

Перегрузка свойств действует на экземпляры: эти четыре обработчика не являются static. Их аргументы нельзя объявлять передаваемыми по ссылке. Чтение того же отсутствующего свойства из его собственного __get не вызывает этот же обработчик бесконечно: это не способ получить «исходное виртуальное значение». Храните реальные данные в отдельном объявленном поле, как data или extra.

Упражнение: измените __isset на array_key_exists и сравните isset(note) с empty(note). Решение: первый ответ станет true, второй останется true, потому что чтение вернёт null. Это наглядно показывает, почему контракт виртуальных полей важнее короткой записи.

Старый __sleep и новый __serialize: что сохраняется между запусками

В старом проекте объект корзины могли класть в сессию целиком. Чтобы читать такой код, нужно различать два договора. __Sleep возвращает имена сохраняемых свойств. __Serialize возвращает сам массив сохраняемых данных: ключи этого массива вы выбираете как формат. Ни один вариант не сохраняет код методов или работающий HTTP-клиент.

Для нового кода используем __serialize/__unserialize и явно восстанавливаем допустимое состояние. Старые __sleep/__wakeup с PHP 8.5 мягко устарели: документация рекомендует замену, однако само их наличие не создаёт автоматическое предупреждение E_DEPRECATED. Ниже локальные строки, произведённые тем же примером; не подставляйте cookie, тело запроса или чужой файл.

Снимок содержит данные, исполняемый код остаётся в проекте
  1. __sleep выбирает свойство sku по имени
  2. serialize сохраняет это свойство, а не временную подпись
  3. unserialize создаёт экземпляр без обычного конструктора
  4. __wakeup заново строит производную подпись
Прочитаем старый механизм на товарной закладкеСамостоятельный пример
<?php
declare(strict_types=1);

class LegacyBookmark
{
    public string $caption = '';
    public function __construct(public string $sku)
    {
        $this->caption = 'Товар ' . $sku;
    }
    public function __sleep(): array { return ['sku']; }
    public function __wakeup(): void { $this->caption = 'Товар ' . $this->sku; }
}

$original = new LegacyBookmark('P-42');
$original->caption = 'Временное оформление';
$saved = serialize($original);
$restored = unserialize($saved, ['allowed_classes' => [LegacyBookmark::class]]);
if (!$restored instanceof LegacyBookmark) {
    throw new UnexpectedValueException('Неверный тип локального снимка');
}
echo $restored->caption, "\n";
echo $restored === $original ? "Тот же объект\n" : "Отдельный экземпляр\n";
Ожидаемый результат
Товар P-42
Отдельный экземпляр

Caption не входит в список __sleep: она собирается заново. Это чтение исторического кода, а не рекомендация нового формата. __Sleep также неудобен для private-полей родителя: простой список имён в потомке не решает различие областей этих свойств.

При наличии обеих пар PHP выбирает современный договорСамостоятельный пример
<?php
declare(strict_types=1);

class CurrentBookmark
{
    public function __construct(public string $sku) {}
    public function __serialize(): array { return ['version' => 1, 'product' => $this->sku]; }
    public function __unserialize(array $data): void
    {
        if (($data['version'] ?? null) !== 1 || !is_string($data['product'] ?? null)) {
            throw new UnexpectedValueException('Неподдерживаемый снимок');
        }
        if (!preg_match('/\AP-[1-9][0-9]*\z/', $data['product'])) {
            throw new UnexpectedValueException('Недопустимый артикул');
        }
        $this->sku = $data['product'];
    }
    public function __sleep(): array { throw new LogicException('Старый путь не нужен'); }
    public function __wakeup(): void { throw new LogicException('Старый путь не нужен'); }
}

$saved = serialize(new CurrentBookmark('P-42'));
$restored = unserialize($saved, ['allowed_classes' => [CurrentBookmark::class]]);
if (!$restored instanceof CurrentBookmark) {
    throw new UnexpectedValueException('Неверный тип локального снимка');
}
echo $restored->sku, "\n";
echo "Современная пара обработала снимок\n";
Ожидаемый результат
P-42
Современная пара обработала снимок

Если бы PHP вызвал старую пару, пример завершился бы исключением. __Unserialize получает собственную схему данных; конструктор его не подстраховывает, поэтому проверка здесь самостоятельная. Allowed_classes ограничивает обычные классы, но не делает недоверенный сериализованный поток безопасным.

Магические имена начинаются с двух подчёркиваний и зарезервированы PHP. Для сериализации обработчики публичны; при указании типов необходимо соблюдать их договор, в том числе array у __serialize и void у __unserialize. Конструктор и деструктор вообще не объявляют возвращаемый тип; __clone может объявлять void. Не переносите произвольную сигнатуру обычного метода в магический.

В старых библиотеках встречается интерфейс Serializable с методами serialize/unserialize. Это отдельный исторический протокол; его реализация без современной пары устарела с PHP 8.1. Миграция требует проверки уже сохранённых записей, а не механического переименования методов: старый и новый форматы не равны.

Упражнение: добавьте в снимок число quantity. Решение: повысить или явно обработать version, проверить целое и допустимый диапазон в __unserialize, затем записать поле. Для межсервисного обмена предпочтите проверяемый JSON с явной схемой, а для сессии часто достаточно ID корзины.

При восстановлении нужен класс: строка не содержит методов

Два HTTP-запроса — два отдельных запуска приложения. В первом создали объект, во втором читают сохранённые данные. Если во втором запуске нет объявления класса или автозагрузчика, PHP не найдёт метод caption по одной строке. Этот опыт использует отдельный дочерний PHP-процесс, чтобы первое объявление класса не попало в память второго.

Сначала посмотрите очередь автозагрузки. В обычном приложении подготовка классов — require vendor/autoload.php до session_start или до восстановления данных. Здесь используем только два собственных файла и явный разрешённый класс.

Два запуска не делят определения классов
  1. producer.php загружает класс и создаёт локальную строку
  2. Главный процесс ещё не знает StoredBookmark
  3. Первое восстановление даёт __PHP_Incomplete_Class
  4. После регистрации загрузчика второе восстановление получает методы класса
Файл StoredBookmark.phpФрагмент или команда
<?php
class StoredBookmark
{
    public function __construct(public string $sku) {}
    public function caption(): string { return 'Товар ' . $this->sku; }
}

Этот файл лежит рядом с main.php и producer.php. Он виден как часть проекта, но до require не определяет класс в каждом другом процессе автоматически.

Файл producer.phpФрагмент или команда
<?php
require __DIR__ . '/StoredBookmark.php';
echo serialize(new StoredBookmark('P-42'));

Выполняется только наш локальный фиксированный файл. Строка снимка не приходит из запроса и не содержит данных пользователя.

Файл main.php: сначала без класса, затем с загрузчикомСамостоятельный пример
<?php
declare(strict_types=1);

$process = proc_open(
    [PHP_BINARY, '-n', __DIR__ . '/producer.php'],
    [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
    $pipes
);
if (!is_resource($process)) { throw new RuntimeException('Не запущен producer'); }
fclose($pipes[0]);
$saved = stream_get_contents($pipes[1]);
$errors = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
if (proc_close($process) !== 0 || $errors !== '') {
    throw new RuntimeException('Не создан локальный снимок');
}

$withoutClass = unserialize($saved, ['allowed_classes' => ['StoredBookmark']]);
echo get_class($withoutClass), "\n";
$loader = static function (string $class): void {
    if ($class === 'StoredBookmark') { require __DIR__ . '/StoredBookmark.php'; }
};
spl_autoload_register($loader);
try {
    $ready = unserialize($saved, ['allowed_classes' => ['StoredBookmark']]);
    if (!$ready instanceof StoredBookmark) { throw new UnexpectedValueException('Нет класса'); }
    echo $ready->caption(), "\n";
} finally {
    spl_autoload_unregister($loader);
}
Ожидаемый результат
__PHP_Incomplete_Class
Товар P-42

Первый неполный объект не превращается в готовый после подключения класса: мы отдельно восстанавливаем исходную строку второй раз. Сохранены имя класса и состояние; реализация caption берётся из текущего файла. При изменении класса разработчик отвечает за совместимость схемы.

Для сохранённого объекта недостаточно «чтобы PHP помнил переменную». Между запросами требуются хранилище и загрузка определений. Переименование namespace тоже может сломать старые снимки: для долгоживущих данных надёжнее хранить версию схемы и обычные значения с явным преобразованием.

Упражнение: замените caption на метод с другим названием после создания снимка. Решение: данные сами не принесут старую реализацию. Код чтения должен соответствовать развёрнутой версии класса; тест совместимости следует выполнять на реальном старом локальном fixture, сохраняя границу доверия.

Читать старые Serializable-снимки и записывать новый формат

Миграция кэша или сессии бывает постепенной: новые процессы уже пишут современный формат, а в хранилище ещё лежат старые данные. При наличии обеих современных магических функций PHP использует их для новой сериализации. Старые методы интерфейса можно временно оставить, чтобы прочитать исторический формат C.

Это продвинутый опыт после двух договоров сериализации. Старый fixture здесь собирается из собственных данных тем же скриптом. Ручной заголовок нужен только для воспроизведения старого формата; в прикладном коде не конструируйте его из пользовательского ввода.

Временный мост между двумя поколениями данных
  1. Старый C-снимок вызывает метод Serializable::unserialize
  2. Он проверяет JSON и заполняет sku
  3. Обычный serialize теперь выбирает __serialize
  4. Новый снимок восстанавливается через __unserialize
Одна версия класса читает два контролируемых форматаСамостоятельный пример
<?php
declare(strict_types=1);

class MigratingBookmark implements Serializable
{
    public static array $paths = [];
    public function __construct(public string $sku) {}

    private function restore(array $data): void
    {
        if (($data['version'] ?? null) !== 1 || !is_string($data['sku'] ?? null)
            || !preg_match('/\AP-[1-9][0-9]*\z/', $data['sku'])) {
            throw new UnexpectedValueException('Неверная схема закладки');
        }
        $this->sku = $data['sku'];
    }
    public function serialize(): string
    {
        return json_encode(['version' => 1, 'sku' => $this->sku], JSON_THROW_ON_ERROR);
    }
    public function unserialize(string $data): void
    {
        self::$paths[] = 'Старый C';
        $decoded = json_decode($data, true, 8, JSON_THROW_ON_ERROR);
        if (!is_array($decoded)) { throw new UnexpectedValueException('Ожидается массив'); }
        $this->restore($decoded);
    }
    public function __serialize(): array { return ['version' => 1, 'sku' => $this->sku]; }
    public function __unserialize(array $data): void
    {
        self::$paths[] = 'Новый O';
        $this->restore($data);
    }
}

$original = new MigratingBookmark('P-42');
$className = MigratingBookmark::class;
$payload = $original->serialize();
$oldFixture = 'C:' . strlen($className) . ':"' . $className . '":'
    . strlen($payload) . ':{' . $payload . '}';
$newFixture = serialize($original);
foreach ([$oldFixture, $newFixture] as $saved) {
    $restored = unserialize($saved, ['allowed_classes' => [MigratingBookmark::class]]);
    if (!$restored instanceof MigratingBookmark) { throw new UnexpectedValueException('Неверный класс'); }
    echo $restored->sku, "\n";
}
echo implode(' / ', MigratingBookmark::$paths), "\n";
Ожидаемый результат
P-42
P-42
Старый C / Новый O

Имя serialize у обычного вызова $original->serialize() и у функции serialize($original) обозначает разные точки входа. Вторая выбирает протокол PHP и теперь вызывает __serialize. Длина в старом заголовке измеряется в байтах через strlen. Этот учебный мост не делает произвольную строку безопасной.

Перед удалением Serializable нужно дождаться истечения старых данных либо явно мигрировать их и проверить сохранённые fixtures. Для долгосрочного хранения полезнее обычная версия JSON и отдельный конструктор восстановления: формат меньше зависит от имени PHP-класса. Упражнение: измените version в собственном старом payload. Решение: общая restore отвергнет неподдерживаемую схему, поэтому оба входа соблюдают одинаковый инвариант.

Функции и методы этой темы

Откройте карточку, чтобы увидеть договор функции, примеры, ограничения и связанные инструменты.

method_existsУзнать, объявлен ли метод, не смешивая это с возможностью и разрешением его вызвать.

PHP 4+ · Core

method_exists(object|string $object_or_class, string $method): bool

Узнать, объявлен ли метод, не смешивая это с возможностью и разрешением его вызвать.

Что происходит в этом вызове
  1. Client содержит private sign
  2. method_exists($client, sign) возвращает true
  3. Внешний вызов sign всё равно запрещён; наличие и доступ — разные вопросы
Вход / результатТочный смысл
$object_or_classОбъект или имя класса; неизвестное имя может включить autoload.
$methodИмя метода без скобок: например listOrders. Имена методов регистронезависимы.
Результатtrue, если объявленный метод найден; иначе false. private/protected текущего типа тоже могут дать true. __call/__callStatic не создают реальные объявления каждого возможного имени.
Ошибки / версииНепригодный тип первого параметра — TypeError в PHP 8. С PHP 7.4 проверка по имени класса не считает унаследованный private-метод объявленным на потомке; проверка по объекту имеет отдельный нюанс ниже.

Функция полезна для диагностики старого подключаемого объекта. Для штатного расширения явный интерфейс лучше набора необязательных методов. Следующая минимальная проверка ничего не вызывает. method_exists также не проверяет, объявлен ли метод static: true для обычного метода по имени класса не разрешает вызов ClassName::method() без объекта в PHP 8. Число и типы аргументов отдельного вызова здесь тоже не проверяются.

Найти метод и отдельно проверить доступность вызоваСамостоятельный пример
<?php
declare(strict_types=1);

class Client
{
    public function listOrders(): array { return ['A-42']; }
    private function sign(): string { return 'internal'; }
}
$client = new Client();
echo method_exists($client, 'listOrders') ? "Список заказов объявлен\n" : "Нет метода списка\n";
echo method_exists($client, 'sign') ? "Внутренняя подпись объявлена\n" : "Нет подписи\n";
echo is_callable([$client, 'sign']) ? "Подпись можно вызвать снаружи\n" : "Внешний вызов подписи закрыт\n";
echo 'Получен заказ: ', $client->listOrders()[0], "\n";
Ожидаемый результат
Список заказов объявлен
Внутренняя подпись объявлена
Внешний вызов подписи закрыт
Получен заказ: A-42

method_exists отвечает о наличии объявления, включая private. Он не даёт разрешение на вызов. Для известного необязательного legacy-метода проверяют и доступность; для обычной архитектуры типизированный интерфейс проще такого исследования.

Безопасно обработать известный необязательный методСамостоятельный пример
<?php
declare(strict_types=1);

class CachedClient
{
    public function clearCache(): string { return 'Кеш очищен'; }
}
class BasicClient {}
foreach ([new CachedClient, new BasicClient] as $client) {
    if (method_exists($client, 'clearCache') && is_callable([$client, 'clearCache'])) {
        echo $client->clearCache(), "\n";
    } else {
        echo "У клиента нет публичного сброса кеша\n";
    }
}
Ожидаемый результат
Кеш очищен
У клиента нет публичного сброса кеша

Имя операции задано разработчиком. && требует обе проверки. Эта совместимость со старым API ещё не проверяет параметры произвольного метода; когда контракт под вашим контролем, введите интерфейс CacheResettable.

Различить проверку класса и экземпляраСамостоятельный пример
<?php
declare(strict_types=1);

class BaseClient { private function sign(): void {} }
class Client extends BaseClient {}
var_dump(method_exists(Client::class, 'sign'));
var_dump(method_exists(new Client, 'sign'));
var_dump(is_callable([new Client, 'sign']));
Ожидаемый результат
bool(false)
bool(true)
bool(false)

Это проверено на PHP 8.5: правило PHP 7.4 об унаследованном private относится к class checks. Ни один результат не разрешает дочернему или внешнему коду вызвать private-метод родителя.

Динамический вызов может пройти is_callable, но быть отклонён адаптеромСамостоятельный пример
<?php
declare(strict_types=1);

class ProxyClient
{
    public function __call(string $name, array $arguments): string
    {
        if ($name !== 'listOrders' || $arguments !== []) { throw new BadMethodCallException('Неизвестная операция'); }
        return 'Заказ A-42';
    }
}
$proxy = new ProxyClient();
echo method_exists($proxy, 'listOrders') ? "Метод объявлен\n" : "Объявления listOrders нет\n";
echo is_callable([$proxy, 'listOrders']) ? "Вызов обслуживает __call\n" : "Вызов недоступен\n";
echo $proxy->listOrders(), "\n";
try { $proxy->eraseAll(); }
catch (BadMethodCallException $error) { echo $error->getMessage(), "\n"; }
Ожидаемый результат
Объявления listOrders нет
Вызов обслуживает __call
Заказ A-42
Неизвестная операция

__call может обслужить несуществующее объявленное имя. is_callable не знает вашу внутреннюю карту разрешённых действий. Поэтому динамический адаптер обязан сам проверять имя и аргументы; эти проверки не заменяют ни method_exists, ни is_callable.

Рядом: get_class_methods, class_exists, property_exists. Как выбрать проверку.

Дополнительные примеры из практической главы

Проверяет наличие метода у известного объекта или класса. Для строкового имени класса возможен запуск автозагрузки. Магический __call не превращает произвольное виртуальное имя в объявленный метод.

Проверить необязательный legacy-hookСамостоятельный пример
<?php
declare(strict_types=1);

final class LegacyPlugin { public function warmup(): void {} }
echo method_exists(new LegacyPlugin(), 'warmup') ? "Hook объявлен\n" : "Нет\n";
Ожидаемый результат
Hook объявлен

Для нового API лучше явный интерфейс; проверка пригодна при интеграции со старым плагином.

Существование не даёт право на вызовСамостоятельный пример
<?php
declare(strict_types=1);

final class Report { private function rebuild(): void {} }
$report = new Report();
echo method_exists($report, 'rebuild') ? "Существует\n" : "Нет\n";
echo is_callable([$report, 'rebuild']) ? "Вызывается\n" : "Недоступен\n";
Ожидаемый результат
Существует
Недоступен

Так обнаруживается разница между структурой класса и доступностью метода.

Рядом: class_exists, instanceof.

__invokeСделать настроенный объект вызываемым как функцию.

PHP 8.2+ · Core

public function __invoke(...$arguments): mixed // собственная сигнатура

Вызов $object(...) передаёт управление __invoke. Аргументы и возвращаемый тип определяет ваша операция. Объект можно передавать там, где требуется совместимый callable.

Параметризованный форматтер артикулаСамостоятельный пример
<?php
declare(strict_types=1);

final class SkuPrefix {
    public function __construct(private string $prefix) {}
    public function __invoke(string $sku): string { return $this->prefix.$sku; }
}
echo (new SkuPrefix('SHOP-'))('TEA'), "\n";
Ожидаемый результат
SHOP-TEA

Настройка задаётся один раз, вход меняется при каждом вызове.

Переиспользуемый предикат наличияСамостоятельный пример
<?php
declare(strict_types=1);

final class InStock {
    public function __invoke(array $row): bool { return isset($row['stock']) && is_int($row['stock']) && $row['stock'] > 0; }
}
$rows = [['sku'=>'A','stock'=>0],['sku'=>'B','stock'=>2]];
echo implode(',', array_column(array_filter($rows, new InStock()), 'sku')), "\n";
Ожидаемый результат
B

Класс даёт условию имя. Проверка type защищает от неожиданной структуры входа.

Рядом: __construct, instanceof.

__toStringОпределить текстовое представление объекта.

PHP 8.2+ · Core

public function __toString(): string

Используется в строковом контексте. Класс с __toString неявно реализует Stringable; явное implements делает намерение заметнее. Возвращайте осмысленную строку.

Читаемый номер поставкиСамостоятельный пример
<?php
declare(strict_types=1);

final readonly class ShipmentNumber {
    public function __construct(private int $number) {}
    public function __toString(): string { return 'SHIP-'.str_pad((string)$this->number, 4, '0', STR_PAD_LEFT); }
}
echo new ShipmentNumber(27), "\n";
Ожидаемый результат
SHIP-0027

Форматирование находится рядом с правилом представления номера.

Понятное значение в текстовом журналеСамостоятельный пример
<?php
declare(strict_types=1);

final readonly class CurrencyCode {
    public function __construct(private string $value) {
        if (!in_array($value, ['RUB','EUR'], true)) { throw new InvalidArgumentException('Валюта'); }
    }
    public function __toString(): string { return $this->value; }
}
echo 'Валюта заказа: '.new CurrencyCode('RUB'), "\n";
Ожидаемый результат
Валюта заказа: RUB

Выводится только разрешённый код, а не весь объект с внутренними данными.

Рядом: JsonSerializable::jsonSerialize, get_class.

JsonSerializable::jsonSerializeЗадать явную JSON-проекцию объекта.

PHP 5.4+ · json

public function jsonSerialize(): mixed

Метод выбирает, какие PHP-данные представляют объект во внешнем JSON. У него нет параметров. Он возвращает значение для дальнейшего json_encode, а не обязан возвращать уже готовую строку. Возврат mixed допускает массив, скаляр, null или подходящий объект; ресурс и циклическая структура не являются пригодным результатом.

Метод вызывается кодировщиком
  1. new StockView() создаёт объект.
  2. json_encode($view) обнаруживает implements JsonSerializable.
  3. Вызов $view->jsonSerialize() возвращает ["stock" => 0].
  4. Кодировщик возвращает строку {"stock":0}; echo выводит её.

Перед примером разберём запись: class объявляет класс, implements обещает выполнение интерфейса, public разрешает кодировщику вызвать метод. function объявляет метод, пустые скобки означают отсутствие параметров, : mixed допускает разные типы результата. return отдаёт значение и завершает метод. new создаёт экземпляр объявленного класса.

Самый маленький объект с выбранным представлениемСамостоятельный пример
<?php
class StockView implements JsonSerializable
{
    public function jsonSerialize(): mixed
    {
        return ["stock" => 0];
    }
}
$view = new StockView();
echo json_encode($view, JSON_THROW_ON_ERROR);
Ожидаемый результат
{"stock":0}

Объект $view передан кодировщику. Мы не вызываем jsonSerialize вручную: json_encode делает это по договору интерфейса, получает массив stock → 0 и возвращает JSON-текст. Метод и формат ответа можно менять в одном месте.

Стабильный публичный ответ из нескольких объектовСамостоятельный пример
<?php
class ItemView implements JsonSerializable
{
    public function __construct(private string $sku, private int $stock) {}

    public function jsonSerialize(): mixed
    {
        return ["sku" => $this->sku, "stock" => $this->stock];
    }
}
$response = ["items" => [new ItemView("0041", 0), new ItemView("0072", 3)]];
echo json_encode($response, JSON_THROW_ON_ERROR);
Ожидаемый результат
{"items":[{"sku":"0041","stock":0},{"sku":"0072","stock":3}]}

Конструктор получает данные экземпляра и сохраняет их в private-свойства; $this указывает на текущий объект. При обходе массива кодировщик вызывает метод каждого ItemView. Массив items остаётся списком. Внутри метода не вызываем encode повторно, поэтому клиент получает вложенные объекты, а не строки с экранированным JSON.

Представление может быть строкой, а не набором свойствСамостоятельный пример
<?php
class ShipmentCode implements JsonSerializable
{
    public function jsonSerialize(): mixed
    {
        return "0041";
    }
}
echo json_encode(["code" => new ShipmentCode()], JSON_THROW_ON_ERROR);
Ожидаемый результат
{"code":"0041"}

Интерфейс не требует массива. Строковое представление удобно для отдельного типа кода. При декодировании обратно в PHP вы получите строку "0041", а не новый ShipmentCode; восстановление собственных объектов — отдельная явная операция.

Метод может быть вызван при каждом encode, поэтому не прячьте в нём сетевой запрос, изменение базы или отправку уведомления. Лучше готовить данные заранее и делать представление предсказуемым. Если метод сам бросает исключение, оно может пройти наружу как есть; JsonException не ловит автоматически ошибки любого класса. __sleep/__serialize относятся к serialize, а __toString не является договором JSON. Не возвращайте постоянно новый клон самого сериализуемого объекта: это может создать бесконечную цепочку представлений.

Нельзя заменять явный список полей на (array) $this или get_object_vars($this) без анализа: внутри класса доступны и закрытые данные. Правила JSON-ошибок и флаги относятся и к результату этого метода.

Дальше: предметные модели.

json_encode вызывает этот метод и сериализует его результат. Обычно возвращают массив скаляров/вложенных DTO. Ошибки кодирования контролируйте JSON_THROW_ON_ERROR у вызывающего json_encode.

Публичная карточка пользователяСамостоятельный пример
<?php
declare(strict_types=1);

final class UserCard implements JsonSerializable {
    public function __construct(private int $id, private string $secret) {}
    public function jsonSerialize(): mixed { return ['id'=>$this->id]; }
}
echo json_encode(new UserCard(7, 'demo'), JSON_THROW_ON_ERROR), "\n";
Ожидаемый результат
{"id":7}

Выбор разрешённых полей независим от количества внутренних свойств.

Деньги в стабильном формате APIСамостоятельный пример
<?php
declare(strict_types=1);

final readonly class Price implements JsonSerializable {
    public function __construct(private int $minor) {}
    public function jsonSerialize(): mixed { return ['minor'=>$this->minor,'currency'=>'RUB']; }
}
echo json_encode(['price'=>new Price(15900)], JSON_THROW_ON_ERROR), "\n";
Ожидаемый результат
{"price":{"minor":15900,"currency":"RUB"}}

Не передаём плавающую десятичную сумму с неявным округлением; единицы объявлены в имени.

Рядом: get_object_vars, __toString.

Источники и обсуждения

Код и руководство с собой

Скачайте самостоятельные примеры с исходными данными, инструкциями и ожидаемыми результатами. Примеры HTTP и фрагменты для проекта помечены отдельно: им нужно указанное окружение.

Код не исполняется на сайте. Скачайте нужный пример и запускайте его в своём локальном окружении.

Материалы сверены 2026-10-12. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.