Как правильно структурировать модель в MVC на PHP? Проблемы с Fat Model и бизнес-логикой
Всем привет! Я начинающий PHP-разработчик, и уже несколько месяцев пишу на Laravel. Но я чувствую, что делаю что-то не так с моделями.
У меня есть проект интернет-магазина. Изначально я складывал всю логику в модели Eloquent. Например, модель Order у меня содержит:
class Order extends Model
{
public function calculateTotalWithDiscount($couponCode) { ... }
public function validateStock() { ... }
public function sendConfirmationEmail() { ... }
public function generateInvoicePdf() { ... }
public function applyLoyaltyPoints($userId) { ... }
public function checkFraud() { ... }
}
Сейчас в этой модели уже 2000+ строк. Я понимаю, что это "жирная модель" (Fat Model), но не знаю, как правильно разделить ответственность. В интернете много споров: одни говорят, что модель должна быть только для работы с БД, другие — что вся бизнес-логика должна быть в модели.
Подскажите, как правильно организовать код? Куда выносить расчёты, отправку писем, проверки? Есть ли какие-то паттерны, которые помогут не превратить модель в "свалку"? И что вообще должна содержать идеальная модель в MVC?
Заранее спасибо за развёрнутый ответ!
<<Модель в MVC: не просто ORM-сущность, а слой доменной логики
Ваша проблема — классический симптом неправильного понимания MVC. Вы смешиваете три разные сущности: запись в БД (Active Record/Entity), бизнес-логику (Service/Domain) и инфраструктурный код (Mailer, PDF, etc.).
Правильная архитектура модели
Модель в MVC — это слой, а не один класс. Вот рекомендуемая структура для PHP-приложений:
1. Entity (Сущность) — только данные
class Order
{
public function __construct(
private int $id,
private float $subtotal,
private string $status,
private Customer $customer,
private array $items
) {}
// Только геттеры и простые вычисления без внешних зависимостей
public function getTotal(): float
{
return $this->subtotal + $this->getTax();
}
private function getTax(): float
{
return $this->subtotal * 0.2; // 20% НДС
}
}
2. Repository (Репозиторий) — доступ к данным
interface OrderRepositoryInterface
{
public function findById(int $id): ?Order;
public function save(Order $order): void;
}
class EloquentOrderRepository implements OrderRepositoryInterface
{
public function findById(int $id): ?Order
{
$record = OrderModel::find($id); // Eloquent модель
if (!$record) return null;
return new Order(
id: $record->id,
subtotal: $record->subtotal,
status: $record->status,
customer: $this->hydrateCustomer($record->customer),
items: $this->hydrateItems($record->items)
);
}
}
3. Service (Сервис) — бизнес-логика
class OrderService
{
public function __construct(
private OrderRepositoryInterface $orderRepo,
private DiscountCalculator $discountCalc,
private MailerInterface $mailer
) {}
public function applyDiscount(int $orderId, string $couponCode): void
{
$order = $this->orderRepo->findById($orderId);
// Бизнес-правила
if ($order->getStatus() !== 'pending') {
throw new DomainException('Only pending orders can be discounted');
}
$discount = $this->discountCalc->calculate($order, $couponCode);
$order->applyDiscount($discount);
$this->orderRepo->save($order);
$this->mailer->send('discount_applied', $order);
}
}
4. DTO (Data Transfer Object) — для передачи данных
class OrderResponseDTO
{
public function __construct(
public readonly int $id,
public readonly float $total,
public readonly string $status,
public readonly array $items
) {}
}
Что куда класть — шпаргалка
- В Entity: только свойства и методы, которые работают с этими свойствами (getters, простые вычисления без БД/API)
- В Repository: SQL-запросы, работа с ORM, кэширование на уровне данных
- В Service: бизнес-правила, оркестрация нескольких репозиториев, вызов внешних сервисов
- Отдельные классы: Mailer, PDFGenerator, DiscountCalculator — это инфраструктура
Почему это работает
Такая архитектура даёт:
- Тестируемость — можно мокать репозиторий и тестировать только логику сервиса
- Изоляцию — смена ORM (Eloquent → Doctrine) не трогает бизнес-логику
- Переиспользование — один сервис можно вызывать из контроллера, консольной команды или API
Ваша Order модель Eloquent должна остаться только как прослойка для БД. Всю бизнес-логику выносите в сервисы. И да, 2000 строк — это нормально для начала, но теперь вы знаете, как это исправить.
Альтернативный подход: CQRS + Action-классы
Если сервисы кажутся слишком громоздкими, попробуйте паттерн Command/Query Separation (CQRS) в связке с Action-классами. Вместо одного сервиса с кучей методов, вы создаёте отдельный класс на каждое действие:
class ApplyDiscountAction
{
public function __construct(
private OrderRepositoryInterface $orderRepo,
private DiscountCalculator $calculator
) {}
public function execute(int $orderId, string $couponCode): Order
{
$order = $this->orderRepo->findById($orderId);
$order->applyDiscount($this->calculator->calculate($order, $couponCode));
$this->orderRepo->save($order);
return $order;
}
}
// В контроллере:
$action = new ApplyDiscountAction($orderRepo, $calculator);
$order = $action->execute($request->orderId, $request->coupon);
Плюсы:
- Один класс — одна ответственность (SRP)
- Легко тестировать — просто вызываете execute()
- Можно легко добавить логирование или транзакции через декораторы
Минусы:
- Много маленьких классов (но это не минус, а особенность)
- Сложнее увидеть общую картину бизнес-процесса
Лично я комбинирую оба подхода: для простых CRUD использую сервисы, для сложных бизнес-процессов (оформление заказа, регистрация) — Action-классы. Главное — не класть логику в Eloquent-модели и в контроллеры.