Работа с моделью в 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;
}
// другие методы
}Паттерн декоратор позволяет добавить кэширование без изменения основной реализации репозитория. Подходит для приложений с высокой нагрузкой.