Урок 05

Eloquent: Active Record vs Data Mapper

Два подхода к ORM

  • Active Record — модель знает о БД. Eloquent, Rails AR.
  • Data Mapper — модель о БД не знает. Doctrine, Hibernate.

Active Record (Eloquent)

Модель = строка БД + методы:

$user = User::find(1);
$user->name = 'Иван';
$user->save();

Модель умеет:

  • Загружать себя.
  • Сохранять себя.
  • Удалять себя.
  • Связи.

Data Mapper (Doctrine)

Модель — POJO. Менеджер — сохраняет:

$user = new User('Иван');
$entityManager->persist($user);
$entityManager->flush();

Модель:

  • Не знает о БД.
  • Не имеет save().
  • Просто данные + логика.

Плюсы Active Record

  • Просто. Один объект.
  • Кратко. Меньше boilerplate.
  • Интуитивно. $user->save().
  • Идеально для CRUD.

Минусы Active Record

  • Связанность с БД.
  • Тяжело тестировать без БД.
  • Логика + персистентность в одном классе.
  • SRP — спорно.

Плюсы Data Mapper

  • Разделение. Модель — данные + логика. Mapper — БД.
  • Гибкость. Смена БД.
  • Тестируемость. Модель без БД.
  • DDD — идеально.

Минусы Data Mapper

  • Сложнее. Больше кода.
  • Много boilerplate.
  • Учить дольше.

Когда что

Active Record (Eloquent):

  • Laravel-проекты.
  • CRUD-приложения.
  • Средняя сложность.
  • Быстрый старт.

Data Mapper (Doctrine):

  • Symfony-проекты.
  • DDD / сложная предметная область.
  • Много бизнес-логики.
  • Смена хранилищ.

Как Eloquent реализован

Illuminate\Database\Eloquent\Model — базовый класс.

Внутри:

  • __get, __set — магические свойства.
  • __call, __callStatic — методы запроса.
  • Builder — fluent-интерфейс.
  • Relations — связи.

Пример:

User::where('active', true)->orderBy('name')->get();

where — через __callStatic → Builder → SQL.

Магия Eloquent

Свойства берутся из БД:

$user = User::find(1);
echo $user->name;   // SELECT * FROM users WHERE id=1, затем ->attributes['name']

__get('name') → $this->attributes['name'].

Скрытые запросы

⚠️ N+1:

$posts = Post::all();

foreach ($posts as $post) {
    echo $post->user->name;   // SELECT * FROM users WHERE id=... для каждого поста
}

Решение: with():

$posts = Post::with('user')->get();

Аксессоры и мутаторы

Старый стиль:

public function getNameAttribute(): string
{
    return ucfirst($this->attributes['name']);
}

public function setNameAttribute(string $value): void
{
    $this->attributes['name'] = strtolower($value);
}

Новый стиль (Laravel 9+):

protected function name(): Attribute
{
    return Attribute::make(
        get: fn(string $value) => ucfirst($value),
        set: fn(string $value) => strtolower($value),
    );
}

Casts

Типы:

protected $casts = [
    'is_active' => 'boolean',
    'created_at' => 'datetime',
    'meta' => 'array',
    'status' => OrderStatus::class,
];

Что даёт: типизация, автоматические преобразования.

Events модели

Hooks:

protected static function booted(): void
{
    static::creating(function (User $user) {
        $user->email = strtolower($user->email);
    });

    static::deleted(function (User $user) {
        $user->comments()->delete();
    });
}

Observer:

class UserObserver
{
    public function created(User $user): void
    {
        Mail::to($user)->send(new Welcome());
    }
}

User::observe(UserObserver::class);

Scopes

Переиспользуемые условия:

public function scopeActive(Builder $query): Builder
{
    return $query->where('active', true);
}

User::active()->get();

Параметризованные:

public function scopeRole(Builder $query, string $role): Builder
{
    return $query->where('role', $role);
}

User::role('admin')->get();

Связи

Разные типы:

public function posts(): HasMany
{
    return $this->hasMany(Post::class);
}

public function user(): BelongsTo
{
    return $this->belongsTo(User::class);
}

public function tags(): BelongsToMany
{
    return $this->belongsToMany(Tag::class);
}

Repository поверх Eloquent

Дополнительный слой:

interface UserRepository
{
    public function find(int $id): ?User;
    public function save(User $user): void;
}

final class EloquentUserRepository implements UserRepository
{
    public function find(int $id): ?User
    {
        return User::with('posts')->find($id);
    }

    public function save(User $user): void
    {
        $user->save();
    }
}

Плюс: абстракция. Минус: дублирование.

Когда НЕ нужен Repository

⚠️ В Laravel часто без Repository:

  • Eloquent уже даёт абстракцию.
  • CRUD простой.
  • YAGNI.

Repository — для сложной логики и тестов.

Практика: Eloquent разбор

<?php

// Модель
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Casts\Attribute;

class User extends Model
{
    protected $fillable = ['name', 'email', 'is_active'];

    protected $casts = [
        'is_active' => 'boolean',
        'created_at' => 'datetime',
    ];

    public function posts(): HasMany
    {
        return $this->hasMany(Post::class);
    }

    public function scopeActive($query)
    {
        return $query->where('is_active', true);
    }

    protected function name(): Attribute
    {
        return Attribute::make(
            get: fn(string $value) => ucfirst($value),
            set: fn(string $value) => trim($value),
        );
    }

    protected static function booted(): void
    {
        static::creating(function (User $user) {
            $user->email = strtolower($user->email);
        });
    }
}

Использование:

$user = User::active()->find(1);
$user->name = 'иван';       // сохранится как 'иван'
echo $user->name;           // 'Иван' (аксессор)
$user->is_active;           // true/false
$user->created_at;          // Carbon instance

$posts = $user->posts;      // SELECT * FROM posts WHERE user_id=1

Итог

  • Active Record — модель знает о БД.
  • Data Mapper — модель не знает.
  • Eloquent — Active Record.
  • Магия — через __get, __call.
  • N+1 — используй with().
  • Аксессоры/мутаторы — преобразования.
  • Casts — типы.
  • Events — hooks.
  • Scopes — переиспользуемые условия.
  • Repository — не всегда нужен.

Дальше — Eloquent: связи через ООП.