Работа с моделью в PHP-фреймворках: от простого к сложному

Раздел: Фреймворки PHP

Модель в архитектуре MVC

Модель представляет собой центральный компонент архитектуры MVC, отвечающий за бизнес-логику, работу с данными и их валидацию. В PHP-фреймворках существует несколько подходов к реализации модели, каждый из которых имеет свои цели и области применения. Ниже рассматривается основное эффективное решение на основе Eloquent ORM, а также альтернативные варианты.

Основное решение: Eloquent ORM (Active Record)

Этот подход сочетает простоту и мощь, позволяя каждой таблице базы данных соответствовать отдельный класс модели. Eloquent обеспечивает удобный синтаксис для выполнения запросов, работы со связями и валидации. Цель - ускорить разработку при сохранении читаемости кода.

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

// Получение всех пользователей
$users = User::all();

// Поиск по ключу
$user = User::find(1);

// Создание
User::create(['name' => 'Анна', 'email' => 'anna@example.com']);

Пример иллюстрирует простоту операций. Eloquent автоматически использует именование по соглашению (таблица users, первичный ключ id, временные метки created_at и updated_at). Это решение удобно для проектов средней сложности и микросервисов.

Типичные проблемы и их решение

  • Проблема N+1 запросов: при выводе списка пользователей с постами каждый запрос к связанным данным выполняется отдельно. Решение: использовать жадную загрузку with('posts').
  • Массовое присваивание: через fillable или guarded контролируется, какие поля можно заполнять массово. Ошибка - отсутствие такой защиты приводит к уязвимости.
  • Производительность: при большом количестве записей лучше использовать chunk() или cursor() для итерации.

Случаи использования: быстрые прототипы, приложения с четкой структурой БД, команды, знакомые с Laravel.

Вариант 1: Doctrine ORM (Data Mapper)

Как реализовать модель с полным разделением бизнес-логики и постоянства данных?

Doctrine отделяет сущности (объекты предметной области) от репозиториев, управляющих их сохранением. Это позволяет избежать жесткой привязки к схеме БД.

// Сущность (Entity)
/**
 * @Entity @Table(name="users")
 */
class User
{
    /** @Id @Column(type="integer") @GeneratedValue */
    private $id;
    /** @Column(type="string") */
    private $name;

    public function getId(): ?int
    {
        return $this->id;
    }
    // getters/setters...
}

// Репозиторий
$entityManager = EntityManager::create($connection, $config);
$userRepository = $entityManager->getRepository(User::class);
$user = $userRepository->find(1);

Здесь модель (сущность) не содержит методов запросов. Всё взаимодействие идет через EntityManager. Это решение подходит для крупных проектов с многоуровневой логикой.

Проблемы и решения

  • Сложность настройки: требуется конфигурация прокси, метаданных. Помогает использование Symfony Bundle, автоматически интегрирующего Doctrine.
  • Производительность: при сложных запросах Doctrine может создавать множество запросов. Используйте QueryBuilder и DQL для оптимизации.
  • Ленивая загрузка: приводит к N+1, если не указать явные join-ы. Решение - использовать ->leftJoin() и выборку fetch mode.

Вариант 2: Ручная реализация через PDO

Как создать модель без ORM для полного контроля над SQL и максимальной производительности?

Этот вариант подходит для микропроектов или ситуаций, где требуется специфическая оптимизация запросов. Модель становится оберткой над PDO.

class UserModel
{
    private PDO $pdo;

    public function __construct(PDO $pdo)
    {
        $this->pdo = $pdo;
    }

    public function find(int $id): ?array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM users WHERE id = :id');
        $stmt->execute([':id' => $id]);
        return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
    }

    public function all(): array
    {
        return $this->pdo->query('SELECT * FROM users')->fetchAll(PDO::FETCH_ASSOC);
    }
}

$model = new UserModel($pdo);
$user = $model->find(1);

Здесь модель отвечает только за доступ к данным. Бизнес-логика должна располагаться отдельно. Цель - минимальный вес и полный контроль.

Проблемы и решения

  • SQL-инъекции: всегда использовать подготовленные запросы. Не допускать конкатенации с внешними данными.
  • Дублирование кода: каждый метод повторяет паттерн prepare/execute. Автоматизировать через генерацию методов или использование Query Builder.
  • Тестирование: сложно изолировать PDO. Обернуть в интерфейс и внедрять моки.

Вариант 3: Паттерн Repository

Как абстрагировать доступ к данным для замены источника хранения (БД, API)?

Repository изолирует логику доступа, предоставляя коллекции объектов. Модель остается чистой сущностью.

interface UserRepositoryInterface
{
    public function findById(int $id): ?User;
    public function findByEmail(string $email): ?User;
    public function save(User $user): void;
}

class PDOUserRepository implements UserRepositoryInterface
{
    private PDO $pdo;

    public function findById(int $id): ?User
    {
        // ... реализация
    }
    // остальные методы
}

Использование репозитория позволяет внедрять разные реализации без изменения бизнес-логики. Цель - расширяемость и тестирование.

Проблемы и решения

  • Избыточность: для простых CRUD вводится много классов. Можно использовать обобщенный репозиторий через абстрактный класс.
  • Сложность запросов: при фильтрации и сортировке требуется Specification pattern. В простых случаях достаточно методов с параметрами.
  • Комбинирование с ORM: репозиторий может быть прослойкой над Eloquent или Doctrine.

Расширенные примеры работы с моделью

Пример 1: Связи в Eloquent и жадная загрузка

Пример
class User extends Model
{
    public function posts()
    {
        return $this->hasMany(Post::class);
    }

    public function roles()
    {
        return $this->belongsToMany(Role::class);
    }
}

// Жадная загрузка
$users = User::with(['posts', 'roles'])->get();
foreach ($users as $user) {
    echo $user->name . ' - постов: ' . $user->posts->count();
    echo ' ролей: ' . $user->roles->pluck('name')->implode(', ');
}
Анна - постов: 3 ролей: admin, editor
Иван - постов: 0 ролей: user

Без with каждый вызов $user->posts породил бы отдельный запрос. Пример показывает, как избежать N+1.

Пример 2: Глобальные скоупы в Eloquent

Пример
class ActiveScope implements Scope
{
    public function apply(Builder $builder, Model $model)
    {
        $builder->where('active', 1);
    }
}

class User extends Model
{
    protected static function booted()
    {
        static::addGlobalScope(new ActiveScope);
    }
}

// Все запросы к User автоматически фильтруют активных
$users = User::all(); // SELECT * FROM users WHERE active = 1
(результат только с active=1)

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

Пример 3: События модели в Eloquent

Пример
class User extends Model
{
    protected static function booted()
    {
        static::creating(function ($user) {
            $user->uuid = Str::uuid();
        });

        static::updated(function ($user) {
            Log::info('User updated: ' . $user->id);
        });
    }
}

// Создание пользователя автоматически добавит uuid
$user = User::create(['name' => 'Петр']);
echo $user->uuid; // что-то вроде '550e8400-e29b-41d4-a716-446655440000'

События позволяют внедрять логику без изменения вызывающего кода. Ошибка: забыть вызвать parent::boot(), если переопределяется boot().

Пример 4: Использование DQL в Doctrine для сложных отчетов

Пример
$query = $entityManager->createQuery(
    'SELECT u, COUNT(p.id) as postCount FROM App\Entity\User u
     LEFT JOIN u.posts p GROUP BY u.id HAVING COUNT(p.id) > 0'
);
$users = $query->getResult();
foreach ($users as $row) {
    echo $row[0]->getName() . ' - ' . $row['postCount'] . ' постов';
}
Анна - 3 постов
Иван - 5 постов

DQL близок к SQL, но оперирует объектами. Проблема: сложность отладки при неправильных алиасах.

Пример 5: Транзакции в PDO с подготовленными запросами

Пример
$pdo->beginTransaction();
try {
    $stmt = $pdo->prepare('INSERT INTO users (name, email) VALUES (:name, :email)');
    $stmt->execute([':name' => 'Мария', ':email' => 'maria@example.com']);

    $stmt2 = $pdo->prepare('INSERT INTO profiles (user_id, bio) VALUES (:user_id, :bio)');
    $stmt2->execute([':user_id' => $pdo->lastInsertId(), ':bio' => 'Биография']);

    $pdo->commit();
} catch (PDOException $e) {
    $pdo->rollBack();
    // логирование ошибки
}

Пример гарантирует атомарность создания пользователя и его профиля. Типичная ошибка - не использовать try-catch или забыть откатить транзакцию.

Пример 6: Repository с кэшированием

Пример
class CachedUserRepository implements UserRepositoryInterface
{
    private UserRepositoryInterface $decorated;
    private Cache $cache;

    public function findById(int $id): ?User
    {
        $key = 'user_' . $id;
        if ($cached = $this->cache->get($key)) {
            return $cached;
        }
        $user = $this->decorated->findById($id);
        if ($user) {
            $this->cache->set($key, $user, 3600);
        }
        return $user;
    }
    // другие методы
}

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

Модель в MVC на PHP - comments

En
Model php (php)