Avatar ·

Как правильно структурировать модель в 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-модели и в контроллеры.