Магия PHP на примере Eloquent Model

1. Введение

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

Например, работая с моделью Laravel, можно обратить внимание на методы и свойства, которых явно нет:

$user = User::ative()->first();
echo $user->name;

Только с опытом, когда погружаешься в исходники фреймворка, начинаешь понимать как используют магию профессионалы.

В данной статье предлагаю рассмотреть Eloquent Model и понять какие магические методы используются в ее основе.

Примеры кода на Laravel 5.8, но принципы работы актуальны и для новых версий.

2. Инициализация и представление

2.1. __construct()

Инициализирует модель и обрабатывает начальные данные.

public function __construct(array $attributes = [])
{
    $this->bootIfNotBooted();      // Загрузка boot-методов
    $this->initializeTraits();     // Инициализация трейтов
    $this->syncOriginal();         // Сохранение оригинальных значений
    $this->fill($attributes);      // Заполнение атрибутов
}

Конструктор гарантирует, что модель всегда находится в корректном состоянии, независимо от способа создания.

$user = new User();
// или
$user = new User(['name' => 'John', 'email' => 'john@example.com']);

2.2. __toString()

Преобразовывает модель в строку (JSON-представление).

public function __toString()
{
    return $this->toJson();
}

На практике метод делает работу с моделью более удобной и предсказуемой, особенно при отладке и логировании.

$user = User::find(1);

// Все три варианта идентичны:
echo $user;                    // Магия!
echo $user->toJson();          // Явный вызов
echo json_encode($user);       // Стандартный PHP

// Результат: {"id":1,"name":"John",...}

2.3. __wakeup()

Восстанавливает состояние модели после десериализации.

public function __wakeup()
{
    $this->bootIfNotBooted(); // Перезагрузка boot-методов
}

Когда объект модели сериализуется и потом десериализуется, он теряет:

Этот метод гарантирует, что после десериализации модель будет правильно инициализирована. Используется везде, где объекты нужно сохранять и восстанавливать (кеш, очереди, сессии).

$serialized = serialize($user);
$unserialized = unserialize($serialized); // Вызывает __wakeup()

3. Перегрузка методов и свойств

Перегрузка в PHP означает возможность динамически создавать свойства и методы. Эти динамические сущности обрабатываются с помощью магических методов, которые можно создать в классе для различных видов действий.[2]

3.1. __get(), __set()

Эти методы перехватывают обращения к свойствам, которых нет в модели, но которые могут быть атрибутами, отношениями или вычисляемыми свойствами.

__get() – автоматический загрузчик свойств.

public function __get($key)
{
    return $this->getAttribute($key);
}

Когда вызывается:

// Автоматическая загрузка отношения
$posts = $user->posts; // __get('posts') загрузит отношения "на лету"

// Работа с обычными атрибутами
$name = $user->name;   // __get('name') вернет значение атрибута

// Вычисляемые свойства через аксессоры
$fullName = $user->full_name; // Вызовет getFullNameAttribute()

__set() – автоматический установщик свойств.

public function __set($key, $value)
{
    $this->setAttribute($key, $value);
}

Когда вызывается:

$user = new User();

// Установка атрибутов
$user->name = 'John'; // __set('name', 'John')

// Установка отношений
$user->posts = [new Post()]; // __set('posts', [...])

// Автоматическое применение мутаторов
$user->password = 'secret'; // Вызовет setPasswordAttribute()

Более подробно логику обработки свойств модели можно посмотреть в самом трейте Laravel vendor/laravel/framework/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php через методы getAttribute и setAttribute.

3.2. __isset(), __unset()

Эти методы обеспечивают согласованное поведение при работе со свойствами Eloquent моделей, но требуют понимания их внутренней логики.

__isset() – проверка существования свойств.

public function __isset($key)
{
    return $this->offsetExists($key);
}

Когда вызывается:

$user = new User();

// Проверка обычного атрибута
isset($user->name);    // true - вызовет __isset('name')
isset($user->age);     // false - атрибут не существует

// Проверка отношения (даже если оно не загружено)
isset($user->posts);   // true - отношение существует в модели
empty($user->posts);   // true - но отношение еще не загружено

// Правильная проверка загруженного отношения:
if ($user->relationLoaded('posts') && !empty($user->posts)) {
    // Отношение загружено и не пустое
}

// Правильная проверка существования связанных записей:
if ($user->posts()->exists()) {
    // В базе есть связанные записи
}

__unset() – удаление свойств.

public function __unset($key)
{
    $this->offsetUnset($key);
}

Когда вызывается:

$user = User::find(1);

// Удаление атрибута
unset($user->name);    // Вызовет __unset('name')
echo $user->name;      // null - атрибут удален

// Удаление отношения
$user->load('posts');  // Предварительно загружаем отношение
unset($user->posts);   // Вызовет __unset('posts') - удаляет загруженное отношение

3.3. __call(), __callStatic()

Эти методы обеспечивают гибкий API для работы с Eloquent, позволяя использовать удобный синтаксис для запросов, отношений и scopes.

__call() – обработка динамических методов.

public function __call($method, $parameters)
{
    if (in_array($method, ['increment', 'decrement'])) {
        return $this->$method(...$parameters);
    }

    return $this->forwardCallTo($this->newQuery(), $method, $parameters);
}

Когда вызывается:

__callStatic() – обработка статических методов.

public static function __callStatic($method, $parameters)
{
    return (new static)->$method(...$parameters);
}

Когда вызывается:

/* Scope через __call() */

// __callStatic() создает экземпляр и вызывает __call()
$activeUsers = User::active()->get();


/* Отношения через __call() */

$user = User::find(1);

// __call() создает экземпляр отношения
$profile = $user->profile();           // Возвращает Relation объект
$orders = $user->orders();             // Возвращает Relation объект

// Дальнейшие вызовы идут уже на Relation объект
$recentOrders = $user->orders()->latest()->limit(5)->get();


/* Query Builder делегирование */

// Все через __callStatic() и __call()
$articles = Article::where('published', true)
                  ->where(function($query) {
                      $query->where('featured', true)
                            ->orWhere('views', '>', 1000);
                  })
                  ->orderBy('created_at', 'desc')
                  ->with('author')
                  ->get();


/* Обработка ошибок */

// Это вызовет MethodNotFoundException
User::nonExistentMethod();

// Это вызовет BadMethodCallException
$user = new User();
$user->nonExistentMethod();


/* Важные особенности */

// Разница между отношением как свойство и как метод:
$user = User::find(1);

// Как свойство (через __get()) - возвращает коллекцию
$posts = $user->posts;  // Коллекция постов

// Как метод (через __call()) - возвращает Relation объект
$postsQuery = $user->posts(); // Builder отношений

// Scope всегда вызывается как метод
$activeUsers = User::active()->get();  // Правильно
// $activeUsers = User::active;        // Неправильно - будет ошибка

4. Заключение

Перечень методов и примеров получился большим, но информативным. Также важно помнить о производительности и понимать, когда магия уместна.

1. Проблема N+1 запросов из-за __get()

// Плохо: каждый вызов $post->author в цикле -> 101 запрос
$posts = Post::all();  // 1 запрос
foreach ($posts as $post) {
    echo $post->author->name; // __get() подгружает автора отдельным запросом
}

// Хорошо: жадная загрузка (eager loading) – всего 2 запроса
$posts = Post::with('author')->get();
foreach ($posts as $post) {
    echo $post->author->name; // автор уже загружен, __get() просто возвращает данные
}

2. Множественные вызовы __get() при доступе к одному атрибуту

// В шаблоне Blade три раза вызывается $user->name
{{ $user->name }}
Welcome, {{ $user->name }}
Last seen: {{ $user->name }}

// Каждый вызов -> __get('name') -> getAttribute('name')
// Решение: сохранить значение в переменную
@php($name = $user->name)
{{ $name }}
...

3. Накладные расходы на __call() и динамическую диспетчеризацию

// Каждый вызов where() проходит через __call() -> forwardCallTo() -> магию
$users = User::where('active', 1)->where('age', '>', 18)->get();

// Для высоконагруженных участков можно использовать прямой Query Builder
$query = DB::table('users'); // обычный объект без магии
$users = $query->where('active', 1)->where('age', '>', 18)->get();
// Разница в микросекундах, но при тысячах вызовов на один запрос она становится ощутимой.

Используйте магию для создания удобного API библиотек, фреймворков, высокоуровневых абстракций.

Избегайте в критических по производительности циклах, в deep-вложенных вызовах, которые выполняются тысячи раз за запрос.

Магия – это не самоцель, а инструмент для создания удобного API. Понимая механику этих методов, вы не только глубже понимаете Laravel, но и учитесь проектировать более качественные абстракции в своих проектах.

5. Источники и ссылки

  1. Магические методы – Руководство по PHP
  2. Перегрузка – Руководство по PHP
  3. Eloquent: Getting Started – Laravel Docs
  4. Eloquent: Relationships – Laravel Docs
  5. Eloquent: Mutators – Laravel Docs
  6. Eloquent: Serialization – Laravel Docs